Author SHA1 Message Date
irisandClaude Fable 5.1 03c6be80a3 iris android-app: header-duplicate investigation, ime-inset fix for keyboard confirmation
Two follow-ups after the keyboard/dp/header pass, both requested against
the P0 box:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cargo fmt --all across the touched files.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

AutoVsync picks Fifo, which every backend supports.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

-1
View File
@@ -1 +0,0 @@
../../.claude/skills/ai-app-rigs
+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 --"
+18 -102
View File
@@ -72,19 +72,13 @@ Each exists because something was invisible without it.
`usage::Fixture`'s, since those are its states. With none set an echo `usage::Fixture`'s, since those are its states. With none set an echo
session meters nothing, which is the ordinary case and draws no bar. session meters nothing, which is the ordinary case and draws no bar.
- **A fake CLI exercises the process lifecycle without a token.** Point a - **A fake CLI exercises the process lifecycle without a token.** Point a
`claude_cli` provider's `command` at a script that ordinarily runs `claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
`cat > /dev/null` and it behaves the way the lifecycle code cares about: `cat > /dev/null` and it behaves the way the lifecycle code cares about:
it holds the fifo open, records a real pid, writes nothing, and dies on a it holds the fifo open, records a real pid, writes nothing, and dies on a
signal. So adopt, stop, restart and start are all drivable without a real signal. So adopt, stop, restart and start are all drivable without a real
`--resume` and without spending a turn on somebody's account. Reach for `--resume` and without spending a turn on somebody's account. Reach for
this when what is under test is *whether a process is running*, and for this when what is under test is *whether a process is running*, and for
`debug-transcript.sh` when it is *what the transcript draws*. The sandbox's `debug-transcript.sh` when it is *what the transcript draws*.
version also handles `auth login`: it prints an inert Anthropic-shaped URL,
rejects any code except `sandbox-code`, and exits successfully for that one.
- **`/think [seconds]` in an echo session puts up a thinking card**, long
enough to watch it spin before it closes with the span it actually took.
The rest of the turn is the ordinary echo reply, so it is also the rig for
a block and a reply meeting.
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens - **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
the first session (or `-k` keeps the current screen), scrolls a fixed the first session (or `-k` keeps the current screen), scrolls a fixed
gesture loop, and prints the app's render report — the same one the in-app gesture loop, and prints the app's render report — the same one the in-app
@@ -147,55 +141,28 @@ moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
### Testing llama.cpp and ssh here ### Testing llama.cpp and ssh here
**Both are set up here** and need nothing typed. The prebuilt llama.cpp lives **Both are set up here as of 2026-09-04** and need nothing typed. The
outside the repo at `~/.local/opt/llama.cpp-vk` — a **Vulkan** build as of prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp`
2026-09-19, replacing the CPU one that was there before — and is symlinked as (the 15 MB `ubuntu-x64` release asset) and is symlinked as
both `~/.local/bin/llama-server` and `/usr/local/bin/llama-server`. The second `/usr/local/bin/llama-server`, which is what makes **discovery find it over
is what makes **discovery find it over ssh**: `~/.local/bin` is not on the ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets.
PATH a non-interactive ssh session gets. It resolves its own libraries through It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is
`$ORIGIN`, so no `LD_LIBRARY_PATH` is needed. needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`,
639 MB under `~/.local/share/ai-app/models` — and answers at usable speed on
Two models are downloaded under `~/.local/share/ai-app/models`: this VM's 8 cores. **Do not test with a 2-bit quant**: the
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
- `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, 639 MB, loads in ~4s. It broken driver — `llama-cli` produces the same from the file directly, which
calls tools correctly and is the right rig for the driver's shape. Do not is how to tell the two apart in a hurry.
judge *answers* by it — asked for the second line of a file it read from
line 2 and then named the third.
- `ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF/Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf`,
12 GB, ~20s to load, and the only one here with a multi-token-prediction
head. It is the rig for anything about `loading` being a state of its own,
since 20s is long enough to send into.
**Do not test with a 2-bit quant**: the IQ2_XXS of the 0.6B produces fluent
nonsense, which reads exactly like a broken driver — `llama-cli` produces the
same from the file directly, which is how to tell the two apart in a hurry.
**The GPU is shared and llama-server dies loudly when it runs out.** A second
server loading a model while the 27B holds VRAM fails with `radv/amdgpu:
Failed to allocate a buffer` / `MESA: error: buffer allocation failed` and
exits mid-request. `-ngl 0` runs it on the 8 cores instead, which is the way
to test the driver while something else holds the card -- through the app, that
is the model's "Layers on the GPU" set to 0 in the machines tab's provider
view, and `--models-max` above 1 is how two models come to be loaded at once
in the first place.
**Testing tools and MCP without the app**: `llama-server --tools all` publishes
its built-in tools at `GET /tools` and runs one at `POST /tools` with
`{"tool": …, "params": …}` and an `x-tool-cwd` header — so a whole agent loop
is drivable with `curl` and no model at all. The Exa MCP server at
`https://mcp.exa.ai/mcp` answers **without an API key** and needs a
`User-Agent` header (Cloudflare answers 403 without one, which reads as a
refusal rather than a missing header).
There is no second machine, so **ssh this VM to itself**. That is set up There is no second machine, so **ssh this VM to itself**. That is set up
too: the key is `~/.config/ai-app/ssh-self` (its public half is in too: the key is `~/.config/ai-app/ssh-self` (its public half is in
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a `~/.ssh/authorized_keys`, labelled removable), and the real config carries a
machine called **"this vm over ssh"** — `bob@127.0.0.1` with that setup called **"this vm over ssh"** — `bob@127.0.0.1` with that
`identityFile` plus `identityFile` plus
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]` `options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
whole rig for "does a remote llama session work", since the far machine is whole rig for "does a remote llama session work", since the far machine is
this one and the model file is the same file. For a throwaway machine of your this one and the model file is the same file. For a throwaway setup of your
own, point a provider's `command` at something harmless like `/bin/echo` own, point a provider's `command` at something harmless like `/bin/echo`
rather than at `claude`: the transport is what is under test, the process rather than at `claude`: the transport is what is under test, the process
exiting immediately is the signal, and it costs no tokens. The remote login exiting immediately is the signal, and it costs no tokens. The remote login
@@ -233,7 +200,7 @@ never be able to close the app, whatever produced it.
**Deleting a session offers to take the machine's own transcript with it** **Deleting a session offers to take the machine's own transcript with it**
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
confirmation, and only where the driver keeps a record of its own confirmation, and only where the driver keeps a record of its own
(`keepsOwnTranscript`, currently Claude Code or Codex). Off by default, (`keepsOwnTranscript`, which today means Claude Code). Off by default,
because leaving that copy is what makes an ordinary delete recoverable — and because leaving that copy is what makes an ordinary delete recoverable — and
the dialog's paragraph is rewritten when it is on rather than appended to, the dialog's paragraph is rewritten when it is on rather than appended to,
since the sentence promising the conversation "should still be there to since the sentence promising the conversation "should still be there to
@@ -243,57 +210,6 @@ where it was instead of half-deleted.
## Measurements worth not re-taking ## Measurements worth not re-taking
- **`-np 1` is what makes the MTP draft head pay.** Taken 2026-09-19 on the
27B above, decode speed for a 300-token reply, from `llama-server`'s own
timings rather than the clock:
| flags | tok/s |
| --- | --- |
| plain, any `-np` | 41.5 |
| `--spec-type draft-mtp -np 1` | 61.4 |
| `--spec-type draft-mtp -np 2` (n-max 2) | 65.9 |
| `--spec-type draft-mtp`, default `-np` (4 slots) | 28 |
Draft acceptance is 0.530.73 in every case, so the head is working in all
of them: what changes is that speculating against a KV cache split four ways
is slower than not speculating. A model's preset gets `parallel = 1` unless
its settings say otherwise (the machines tab's provider view, since
2026-09-19), so this is recorded for whoever next sees MTP look broken or
next raises the slot count to answer two sessions at once. `--spec-draft-n-max 2` was
worth another 7% in a single sample and is deliberately *not* passed — one
sample on a virtualised GPU is not a number to hardcode.
- **Prompt processing is the expensive part of a llama turn here, and decode
speed falls only slowly with context.** Taken 2026-09-19 on a free GPU, the
27B with `--spec-type draft-mtp -np 1`, generating 160 tokens each time:
| context | decode | prefill of that prompt |
| --- | --- | --- |
| 88 | 43.4 tok/s (cold) | 21s |
| 1,569 | 55.5 tok/s | (model still warming) |
| 6,068 | 53.2 tok/s | 9.5s |
| 14,068 | 50.3 tok/s | 22s |
So a turn on a long conversation spends tens of seconds before the first
token, and that is what `SessionStatus::Reading` exists to say. The same
sweep on the 0.6B **on the CPU** falls much harder -- 30.1 tok/s at 44
tokens of context to 11.5 at 6,024 -- which is the shape somebody means by
"it gets slower as the conversation goes on". The figure the app draws is
`timings.predicted_per_second`, decode only, so prefill is never mixed into
it.
- **A busy GPU is a model that will not load at all**, not a slow one:
`radv/amdgpu: Failed to allocate a buffer` and `failed to load model` while
something else holds VRAM. A 0.6B that had been decoding at 149 tok/s ran at
16.7 in that window before its server died, so a tok/s figure taken while
the card is shared says nothing about the model.
- **Asking for the head when the file has none is fatal**, not ignored:
`context type MTP requested but model doesn't contain MTP layers` and the
server exits. Without the flag the same file logs `unused tensor
blk.N.nextn.* — ignoring` and runs normally, which is the state to look for
when MTP is silently not happening.
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU - **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
emulator against a real imported transcript with the server at emulator against a real imported transcript with the server at
`--delay 120`. Settled and flinging fast, both into fresh history and back `--delay 120`. Settled and flinging fast, both into fresh history and back
+17
View File
@@ -55,4 +55,21 @@ components: [
// the terminal the QR would be printed on. // the terminal the QR would be printed on.
enroll: "server/enroll-link.sh", enroll: "server/enroll-link.sh",
), ),
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
// with the same release key as "app" above so the two can install
// over each other -- a separate component, not a mode of "app" above,
// because it is a different applicationId (com.example.aiapp.shell)
// built by a different tool from different sources. No `cwd`: it
// defaults to this checkout's root, which both the `cargo xtask`
// alias (`.cargo/config.toml`, resolved relative to the working
// directory cargo is run from) and `cargo xtask apk`'s own publishing
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
// both need.
Apk(
name: "shell",
modes: ["release", "debug"],
build: "cargo xtask apk",
),
], ],
+19
View File
@@ -1,12 +1,20 @@
.gradle/ .gradle/
build/ build/
app/androidApp/build/ app/androidApp/build/
app/shellApp/build/
local.properties local.properties
.kotlin/ .kotlin/
*.iml *.iml
.idea/ .idea/
.DS_Store .DS_Store
server/target/ server/target/
event-model/target/
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, # Server logs from a development run (ai-server.log by convention,
# wg-test.log from ./test-wg-tunnel.sh). # wg-test.log from ./test-wg-tunnel.sh).
@@ -21,3 +29,14 @@ certs/
config.ron config.ron
config.json config.json
sessions/ sessions/
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
iris/target/
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/
+280 -309
View File
@@ -1,23 +1,18 @@
# ai-app # ai-app
A phone interface to AI coding sessions (Codex, Claude Code and llama.cpp), A phone interface to AI coding sessions (Claude Code and llama.cpp),
replacing the Claude app for daily use. Rust/Axum backend on the desktop, replacing the Claude app for daily use. Rust/Axum backend on the desktop,
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
between them. between them.
**`PLAN.md` is the design source of truth** — every decision with its date, **`docs/PLAN.md` is the design source of truth** — every decision with its
its rationale, and what was rejected. Read it before changing anything date, its rationale, and what was rejected. Read it before changing anything
structural, and update it in place when a decision changes rather than structural, and update it in place when a decision changes rather than
letting this file and the plan become two versions of the truth. This file is letting this file and the plan become two versions of the truth. This file is
the working notes layer: layout, commands, and things that have bitten. the working notes layer: layout, commands, rigs, and things that have bitten.
The design and working documents live under `docs/` — everything except this
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the file and `CLAUDE.md`, which stay at the root because that is where Claude
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and Code and other agent harnesses look for them.
ssh here, how importing behaves, and the measurements not worth re-taking.
They moved there on 2026-09-04 because they are 12 KB that only matter once
you are actually running one, and this file is sent with every request. Read
it before writing or running a benchmark, driving the UI from a script, or
touching the import screen.
The central design point, worth not undoing by accident: **a session is a The central design point, worth not undoing by accident: **a session is a
child process, translated into one common event model.** A new session type child process, translated into one common event model.** A new session type
@@ -30,124 +25,14 @@ Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
`:androidApp` module), same cert scheme, same registry pattern. Read `:androidApp` module), same cert scheme, same registry pattern. Read
dev-updater's `README.md` and `AGENTS.md` before diverging from them. dev-updater's `README.md` and `AGENTS.md` before diverging from them.
Module-by-module intent is in PLAN.md's "Backend layout". Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc - `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
comment is the HTTP table and the surface's source of truth. comment is the HTTP table and the surface's source of truth.
**A machine's models are served by one shared `llama-server`** (2026-09-19,
`session/llama/router.rs`): started with no `-m`, which makes it a
**router** — it reads a preset file naming models and their flags, starts a
child server per model asked for, and routes by the `model` field in each
request. So a session has no process of its own, two sessions on one model
share one copy of it in memory, and a backend restart adopts one process
rather than one per session. Four things fall out of it and are easy to get
wrong again — a session records the router's pid in its own directory as
`process::Detail::Shared`, and `process::stop` refuses to signal a `Shared`
record, which is what keeps one session ending from unloading everybody's
model; **nothing stops a router on its own**, and the only thing that does
is the machine's provider view (`POST /machines/{id}/providers/{p}/stop`);
how a model is *loaded* is per model on its machine
(`ProviderConfig::model_settings`, `LLAMA_MODEL_PARAMS`) rather than per
session, and saving those settings rewrites the preset, which **unloads**
that model; and the preset is read back before every edit, because a router
adopted from an earlier run is serving sections this process has never seen
and rewriting without them unloads those.
**A llama.cpp session runs on its configured machine** (built
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
port the server binds *there* and the port that reaches it *here*, and
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
the command. Three things fell out of it and are easy to get wrong again —
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
because `llama-server` never reads the stdin whose closing ends a CLI and
the same kill left it loaded on the far machine; the model is looked for on
the machine that will serve it, so the spawn screen offers
`GET /machines/{id}/providers/{p}/models` rather than any list of this
backend's own; and the
readiness poll watches the process as well as the port, since a model that
will not load exits in a second and was being reported as "gave up after
300s". See PLAN.md's "Transport" and "llama-server management".
**A llama session has tools and runs the loop itself** (2026-09-19):
`--tools all` gives the router `llama-server`'s built-in set, which it also
*runs* (`GET /tools` for the definitions, `POST /tools` to call one), while
web search comes from an MCP server this backend connects to directly
(`session/llama/mcp.rs`, Exa preset in a discovered provider's
`mcpServers`). Driving the loop is what makes the permission gate ours:
`manual` asks before every call and remembers a tool you answer
"Always allow …" to, `bypassPermissions` never asks, and the allowances are
folded back out of the transcript. Which tools a *session* offers is a
filter applied to those definitions here, not a flag over there: one shared
server has one set, and the filter costs no reload (2,181 tokens of prompt
with all seven, 698 with none). Three more things fall out of it and are
easy to get wrong again — a model change **asks for another model** and
stops nothing, since the one being left may be another session's;
`parallel = 1` unless that model's settings say otherwise, and it is what
decides whether the MTP draft head is a 50% speed-up or a 33% loss; and
`spec-type = draft-mtp` is conditional on the file actually having a head,
because asking for one that is not there makes `llama-server` **exit**.
**A llama session's thinking is drawn** (2026-09-19): `reasoning_content`
becomes `Event::Thinking` deltas closed by an `Event::ThinkingDone` carrying
the span the *driver* measured, and the phone draws a card that spins while
the block is open and says "Thought for 12.4s" once it is not. The reasoning
is deliberately not part of the next prompt (`conversation` ignores it), and
`timings.predicted_per_second` and `timings.prompt_ms` off the same stream
become `UsageDelta`'s `tokensPerSecond` and `prefillMs`, which is the
"read 9.5s · 50.3 tok/s · 3:00 PM" under a finished reply — nothing else here
measures either, so every other driver sends `None`, and the clock is last so
that it does not move when a provider reports fewer of them.
**A turn's wait has two halves and says which** (2026-09-19):
`SessionStatus::Loading` is the model coming off disk and
`SessionStatus::Reading` is `llama-server` processing the prompt -- emitted
when the request goes out and cleared by the first thing the model says, of
any kind. Prefill is the expensive half here (~10s at 6k tokens, ~22s at
14k), and as `running` it looked exactly like thinking. The phone draws
both with the working spinner and its own words, "loading model" and
"reading prompt".
**Thinking effort is a param, and which levels exist is the model's answer**
(2026-09-19): the `thinking` param rides on the request as a chat-template
argument (`reasoning_effort`, or `enable_thinking: false` for `off`), so it
needs no restart -- and the driver asks the loaded server which levels its
template actually takes rather than trusting the offered list, because the
27B raises on `high` and answers to `xhigh`. A level it cannot take is
dropped and said in the transcript, naming the ones it can.
**Every one of those is a default rather than a constant** (2026-09-19):
`DriverKind::params` declares what a provider takes — key, label, shape,
and whether a change waits for a restart — and the phone renders whatever
arrives, on the spawn form and in the session settings dialog. Adding a
setting to a driver is one entry in that table and no app change. `tools`
is in there too, because the seven built-in definitions are ~1,500 tokens
of every prompt, which on a small window is the difference between a usable
session and one that overruns. `DriverKind::model_params` is the same table
for a provider's **models**, drawn in the machines tab's provider view —
the settings that decide how a model is loaded, which belong to the machine
because one loaded copy answers every session using it.
**A model is downloaded onto the machine that will serve it** (2026-09-19,
replacing the fetch this backend used to do onto its own disk, and the
Models tab that went with it). `models.rs` writes a script and a detached
`curl` runs it *there*; the state of a run is a file beside the partial
(`x.gguf.download`), so nothing about it is held in this process — it
survives the phone closing, this backend restarting and a second device
watching, and `kill -0` at each listing is what stops a machine that was
rebooted from leaving a download claiming to be running. The progress is
`wc -c` of the partial against the size HuggingFace published, the sha256
it publishes is what makes a resume safe, and a finished download is not a
state: it is a model, in the list beside the one still going.
Codex is one persistent `codex app-server --stdio` process per session; its
driver uses native turn steering and interruption, persists the protocol
state and thread id, and reads subscription limits through the same CLI
protocol.
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
`AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that `AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
slides the whole main screen over a session from the left (`MainPanel.kt`) (sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
and what it has running beside the turn -- its background tasks over its clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
subagents (`BackgroundTasks.kt`, `SubagentPanel.kt`) -- from the right, both keeping
the session composed underneath; `MainScreen.kt` the root's three tabs
(sessions, import, machines); `Reorder.kt` the drag that moves a row of a
lazy list, used by the session list's handles -- **the order of that list is
the reader's own and nothing sorts it** (`POST /sessions/order`);
`MachineModels.kt` the models on one machine
and the downloads putting them there, drawn inside `ProviderScreen.kt` for a
provider that serves files off that machine's disk; `Api.kt`/`EventStream.kt`
the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
the Keystore-sealed token. the Keystore-sealed token.
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA - `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
@@ -157,17 +42,24 @@ Module-by-module intent is in PLAN.md's "Backend layout".
build without it, since it is a path dependency, which is what keeps the two build without it, since it is a path dependency, which is what keeps the two
projects version-locked to the commit this repo pins. What deliberately did projects version-locked to the commit this repo pins. What deliberately did
**not** move is the API surface and the config *schema*: routes, drivers, **not** move is the API surface and the config *schema*: routes, drivers,
sessions and machines are what makes this project itself. sessions and setups are what makes this project itself.
- `SUBAGENTS.md` — a session's subagents as transcripts of their own - `docs/` — every design and working document except this file and
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt` `CLAUDE.md`:
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the - `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs`
choices made there that are still awaiting review. and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and - `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent.
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`). Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it the opening and stream effects in `SessionScreen.kt`.
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening - `docs/TODO.md` — the working list.
and stream effects in `SessionScreen.kt`. - `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
- `TODO.md` — the working list. branch of the `ai-app-2` clone): what has to be reproduced, the
framework decision, and the ordered experiments with their pass
conditions. Read it before touching anything under that branch.
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
own public API log, working list, decisions log, layout/render design,
and texture-atlas design, and the client-core crate's design,
respectively.
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as - `.dev-updater.ron` — what Dev Updater builds here: the server (run as
`service: Managed(…)`, supervised by Dev Updater's own implementation `service: Managed(…)`, supervised by Dev Updater's own implementation
rather than a script kept here) and the APK, in parallel. It points at rather than a script kept here) and the APK, in parallel. It points at
@@ -204,7 +96,11 @@ two icon buttons the same width without either being given one — and why
:androidApp:compileDebugKotlin :androidApp:lintDebug :androidApp:compileDebugKotlin :androidApp:lintDebug
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the :androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
syntax highlighter, the ANSI parser and the transcript cache — the app's syntax highlighter, the ANSI parser and the transcript cache — the app's
pure logic with no Android in it. pure logic with no Android in it. Touching anything under `BenchFixture.kt`,
`BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs
`:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build
type compiles separately and lint has caught real bugs debug alone never
would (see "Android Lint" below).
- **Android Lint is not optional and is not run by a build.** It found a - **Android Lint is not optional and is not run by a build.** It found a
crash that had been shipping (`java.time` on a minSdk-24 app with crash that had been shipping (`java.time` on a minSdk-24 app with
desugaring off) and later a permission check that silently dropped every desugaring off) and later a permission check that silently dropped every
@@ -260,6 +156,181 @@ two icon buttons the same width without either being given one — and why
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
involved. That is how to verify the wg0-only posture. involved. That is how to verify the wg0-only posture.
## The rigs
Each exists because something was invisible without it.
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
and DECISIONS.md's 2026-09-05 entries), the phone benchmark gate Iris
asked for before porting continues: a deterministic, checked-in synthetic
transcript (`app/bench-fixture/generate.py`, never a real one) that both
this app and iris open with no server, so a frame-time comparison
measures the renderer rather than the data. `./build-apk.sh bench` builds
it — own application id (`com.example.aiapp.bench`) and label ("AI
Sessions bench") so it installs beside a real enrollment rather than
replacing it. Opening it goes straight to a session screen holding the
fixture (no enrollment, no permission prompts) with a "Run benchmark"
control beside "Copy" in session settings: it drives the same scroll loop
and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over
`ui-trace`, but in-process, since a real phone has no usable system
tracing and no agent can drive one (this-machine-android's skill).
`BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a
`URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s
requests from an in-memory copy of the fixture instead of opening a
socket — so the fold, the paging and `uniqueItems` under test are the
screen's real ones, never a shortcut built just for this. The report
gains a `bench:` section (process CPU time, peak RSS, battery current) on
every build, empty except when `BenchRun.kt` filled it in.
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
and data directory, holding eight invented Claude Code transcripts and a
`claude` that is two lines of shell. **That isolation is the point**: the
import screen lists whatever is in `~/.claude/projects`, which in this VM is
real agent transcripts, so exercising *delete* against the ordinary server
deletes somebody's conversation and exercising *import* starts a real
`--resume` on the owner's account.
Its port and root derive from the checkout's name, so two checkouts'
sandboxes cannot reach each other, and its token is generated once into
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
the enrolment flow appended — so the emulator app is enrolled **once** (the
start banner prints the command) and stays enrolled. It shares the real TLS
certificates, because the installed APK pins that CA.
Driving verbs, so none of this is re-derived per session:
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
`./ui-sandbox.sh send SID text|@file`, and
`./ui-sandbox.sh api /path [curl args]`.
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
enrolment already there — for when the fixture under test was expensive to
build; plain `start` wipes them, which is right for the list-screen
fixtures and wrong for that.
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
fake CLI slow to start. Both exist because operations that finish in
milliseconds have states on the way that nothing can observe, and an
unobservable state is one where broken and working look identical.
It also builds a fixture tree at the sandbox home's `~/files` for the
explorer, holding the states otherwise only reachable by finding a real
machine in one: an empty directory, a name with a tab and one with an
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
symlink to a directory and a broken one, a source file per language, and
the three sizes the limits were measured against (`edit-32k.rs`,
`edit-128k.rs`, `big-source.rs`). Point a session at it with
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
The explorer's 409 is produced by editing the file on the machine
(`printf … > file`) between pressing the pencil and pressing save.
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
echo driver is the right rig for most things and the wrong one for anything
whose cost scales with what was actually written: a real reply is longer,
is real markdown, and carries tool calls whose input and output are
kilobytes. Two faults were invisible until a real transcript was loaded — a
page of history landing mid-fling threw the reader back to the newest end,
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
`-b` takes the biggest conversation on the machine rather than the newest,
which is what a scrolling test wants; `--stop` takes it down.
It copies the transcript into `/tmp` and gives the server a `HOME` of its
own, so the import can only see the copy — importing spawns `claude
--resume`, and against the real file that is a second CLI writing to a
conversation somebody may still be in. **A transcript never goes in this
repository**: they hold whatever was said, read and written in that
session, and `~/repos` is shared with the host besides.
- **A fake CLI exercises the process lifecycle without a token.** Point a
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
it holds the fifo open, records a real pid, writes nothing, and dies on a
signal. So adopt, stop, restart and start are all drivable without a real
`--resume` and without spending a turn on somebody's account. Reach for
this when what is under test is *whether a process is running*, and for
`debug-transcript.sh` when it is *what the transcript draws*.
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
the first session (or `-k` keeps the current screen), scrolls a fixed
gesture loop, and prints the app's render report — the same one the in-app
copy button produces, whose `on screen:` line names what the viewport was
holding. Compare two runs with the same gestures; the emulator's absolute
frame times transfer nothing, the report's accounting does. Run it either
side of any change under `Markdown*.kt`, `Transcript*.kt` or
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
that move first are the worst `record: one block`, the reparse mean while
streaming, and the draw phase's accounting line.
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
resets the report, sends FILE, waits for the transcript to stop growing,
and prints. Both of those are corrections to a first version that measured
nothing: a transcript parked further back never redraws while a reply
streams into it, and a session is idle at *both* ends of a turn, so polling
for idle answers before the turn has started.
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
framework, from `atrace` text output with no trace processor needed. It is
how the cost of a layout node per link was attributed to the framework
rather than guessed at.
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
**`iris/android-app/run-bench.sh [--apk PATH]`** installs it on this
checkout's own emulator, taps "Run benchmark" by label, and prints the
report -- written so the P0 build/install/tap/read-report cycle stops
being retyped by hand each time (docs/RUST.md's P0 box).
### Driving the UI
**No script that drives this app's UI presses a coordinate.** Every control
is found by the name it already carries for assistive technology —
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
against the screen at the moment of the gesture and fails the whole run when
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
coordinate is a position measured once by hand, and anything that moves the
control makes the tap land on whatever now sits there — the bench then
reports a number that was never measured, which reads exactly like a result.
Both bench scripts pressed the render report at `tap 723 205` until that
button moved into the session settings dialog on 2026-09-03. The check that
none has crept back:
grep -n "tap [0-9]" app/*.sh
Swipes are still coordinates, deliberately: a gesture across a scrolling area
is a distance rather than a control.
**Two traps in the emulator bench loop**, each of which cost a 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 scroll anchor is per session id,
so the only way two builds start a scroll from the same place is a *fresh
session for each*.
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
`emu up` creates and boots the AVD named after this checkout — whatever `emu
name` prints, never a name typed out here, since this file is the same in
every clone. `run-android.sh` is that plus a build and an install. The `adb`
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
script from `emulator-tools` runs `emu check` before `installDebug`,
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
to every attached device; when it refuses, say which device you mean at the
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
### Testing llama.cpp and ssh here
The prebuilt CPU llama.cpp lives outside the repo at
`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its
own directory on `LD_LIBRARY_PATH`, so start the server as
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
broken driver — `llama-cli` produces the same from the file directly, which
is how to tell the two apart in a hurry.
There is no second machine, so **ssh this VM to itself**: generate a
throwaway key, append the public half to `~/.ssh/authorized_keys`, and
configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches
nothing real. Point a provider's `command` at something harmless like
`/bin/echo` rather than at `claude`: the transport is what is under test, the
process exiting immediately is the signal, and it costs no tokens. **Take the
key back out afterwards.** The remote login shell here is **fish**; the
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
both, but that is luck rather than design, and a shell that is neither is the
thing to suspect first if a remote spawn ever mangles an argument.
## Where things run (host vs this VM) ## Where things run (host vs this VM)
The machine itself — the two boxes, the shared `~/repos` mount, and why the The machine itself — the two boxes, the shared `~/repos` mount, and why the
@@ -268,7 +339,7 @@ means here:
- **`ai-server` belongs on the host in production.** That is where the LAN - **`ai-server` belongs on the host in production.** That is where the LAN
address the phone can reach is, and where WireGuard terminates. address the phone can reach is, and where WireGuard terminates.
`wg-machine-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it `wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
there with `sudo WG_ENDPOINT=<ddns name>`. there with `sudo WG_ENDPOINT=<ddns name>`.
- **The tunnel and the real phone can never terminate in the VM**, because - **The tunnel and the real phone can never terminate in the VM**, because
nothing outside can open a connection into it. Phone bring-up is host work. nothing outside can open a connection into it. Phone bring-up is host work.
@@ -283,7 +354,7 @@ means here:
## Sessions outlive the backend ## Sessions outlive the backend
Since 2026-08-29 a session's process is deliberately left running when Since 2026-08-29 a session's process is deliberately left running when
`ai-server` stops, and adopted again when it starts. PLAN.md has the design; `ai-server` stops, and adopted again when it starts. docs/PLAN.md has the design;
day to day: day to day:
- **Stopping the server no longer stops the sessions.** After `pkill - **Stopping the server no longer stops the sessions.** After `pkill
@@ -301,93 +372,47 @@ day to day:
keep what a development server spawns. The flag decides only what **new** keep what a development server spawns. The flag decides only what **new**
sessions are marked as; what happens on the way out is decided by the sessions are marked as; what happens on the way out is decided by the
**mark**. **mark**.
- **A llama.cpp router is not cleaned up by any of that**, throwaway sessions
included: it belongs to the machine rather than to a session, and a
development server that has loaded a model leaves it loaded — gigabytes of
VRAM — after `pkill ai-server`. Stop it from the machines tab's provider
view, or `pkill -f "[l]lama-server"` when testing.
- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and - Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and
`stderr.log`. `stdout.log` is the driver's input, read from the byte offset `stderr.log`. `stdout.log` is the driver's input, read from the byte offset
in `process.json`; removing either by hand while the session is live loses in `process.json`; removing either by hand while the session is live loses
output or replays it. output or replays it.
## Auto-resume ## Importing
**A session switched to it sends itself a message once the account's usage The import list reports each session's **size as well as its line count**,
limit lifts** — off by default, per session, in the session settings dialog. because the two disagree in the way that matters: these transcripts embed
PLAN.md's "Auto-resume" is the design; day to day: screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
count tells you what continuing a session will cost. Shown, not warned about;
importing a large session is a choice somebody is entitled to make.
- **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time, **Never import a Claude Code session that is open in a terminal.** The app
asks `GET /usage`'s meter for that machine and provider, and only sends when refuses it — see docs/PLAN.md for the incident that made that a refusal rather
it answers `ok` with nothing at 100%. Anything else — still spent, logged than a warning.
out, unreachable — is a longer wait, and a still-spent window reschedules to
the reset time the *meter* now gives.
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
the same `limitReached` event a real driver does, and `/usage 100 5` sets
what the meter answers. They are deliberately separate: the two disagreeing
is the case the design exists for. `/usage 20` is the limit lifting.
- The wait is on the session in `config.ron` (`resume`), so it survives a
backend restart. A day after the limit was hit it gives up and says so in
the transcript.
## A session waiting on its own work **One Claude Code session id can name two files, and the listing offers it
once.** Resuming from a different working directory makes the CLI write a
second transcript with the same id under that directory's project folder — an
ordinary state of a machine, not corruption. Everything downstream addresses
a session by id, and the phone keyed its list on it, so two rows sharing one
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
the copy with the most lines, because the other is usually a few-hundred-byte
stub and is often the *newer* of the two, so recency is the wrong key.
Deleting removes every copy rather than the first, or the row came back after
a delete that reported success. The phone's half is `uniqueItems`, which
every list keyed on a server-chosen id goes through: a repeat there must
never be able to close the app, whatever produced it.
Since 2026-09-06 a session whose turn ended with a **backgrounded subagent or **Deleting a session offers to take the machine's own transcript with it** —
command still running** reports `waiting` rather than `idle` — its own status, `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
drawn as the word "waiting" in `waitingColor` on both screens. `idle` means confirmation, and only where the driver keeps a record of its own
"waiting for a person" and this means the opposite, so it also suppresses the (`keepsOwnTranscript`, which today means Claude Code). Off by default,
"finished" notification, which used to arrive at the one moment it was untrue. because leaving that copy is what makes an ordinary delete recoverable — and
Two things fall out of it and are easy to get wrong again: the queue and the the dialog's paragraph is rewritten when it is on rather than appended to,
held-command boundary release on **either** end-of-turn status, so a message since the sentence promising the conversation "should still be there to
sent while a subagent runs is not held until the subagent finishes; and import again" is exactly the one the switch makes false. The server deletes
`sessionWorking("waiting")` is deliberately **false** — nothing is being the machine's copy *first*, so a machine it cannot reach leaves the session
written, and the fold uses that same predicate to decide a reply is settled. where it was instead of half-deleted.
- **Nothing subagent-specific goes in the main agent's transcript** unless a
subagent sends it a real message that wakes it — which is the peer path, and
already has a row. A row per finished background task was tried and was a
screenful of dividers about work nobody was asking after, one of them a whole
shell command. A subagent's report is its own transcript's closing text and
is read in the subcard.
- **A backgrounded command has no subagent, so its report lands in the tool
card that launched it** — a `ToolUpdate` against the call's own id, replacing
the launch result that says it is still running. `/background [seconds]` in
an echo session is that shape end to end.
- **Two replies that meet are separated by a `TurnBreak`** — a hairline, no
words. The reply that follows a turn boundary is a **new** message: the fold
refuses to grow a settled reply, and without that the two ran together
mid-sentence. `./ui-sandbox.sh` plus `/subagent 3` or `/background 5` in an
echo session is the whole rig; the helpers stagger a second apart so each
reply is its own.
- **Claude's background-task level is authority; two edge sources are the
fallback.** Since Claude Code 2.1.261,
`background_tasks_changed { tasks: [...] }` replaces the live set and repairs
a missed ending edge. Its array size is also the measured `backgroundTasks`
count exposed on the session row and event stream; the phone draws a nonzero
count beside the status rather than deriving one from `waiting` or from the
subagent directory. **What those tasks are is `GET /sessions/{id}/background`**,
listed in the session's right panel above the subagents: runtime state, so it
is never persisted and `null` -- not an empty list -- is what a session with
no process answers. An `ambient` task is dropped from both the list and the
count, on the CLI's own instruction: a live-update watcher is not activity,
and counting one leaves a session `waiting` for ever. An adopted CLI is sent a repeated `initialize` to ask
for the current set. Reconcile only between turns or at a result boundary:
a foreground agent is legitimately absent from a background-only snapshot.
Older CLIs still need both edge sources: `open_tasks` knows about a
backgrounded command, while `Subagents::any_open` finds a subagent whose
`task_started` is behind an adopted stdout offset.
- **A usage limit a subagent hits reaches the session**, not just the
subagent's own transcript; auto-resume can only schedule against a session.
That is the case where the main agent is idle and a background Task is
still burning quota.
- **Codex's count is two id sets added together.** Open child thread ids come
from the subagent registry; live background command process ids come from
app-server's experimental `thread/backgroundTerminals/list`. The command set
is runtime state, refreshed at lifecycle edges and once a second while
nonempty. Never decrement it from an unmatched completion.
- **The status word and its colour are `sessionStatusWord` /
`sessionStatusColour`**, shared by the list and the session screen. They
were two `when`s, and the second one silently missed `waiting`.
## Shared appearance ## Shared appearance
@@ -400,77 +425,10 @@ written, and the fold uses that same predicate to decide a reply is settled.
swallowed the drag along with the tap, so a list could not be scrolled swallowed the drag along with the tap, so a list could not be scrolled
while anything in it was busy. while anything in it was busy.
- **A rate-limit bar belongs to a session's provider, not to its machine.**
One machine offers echo, the Claude CLI and a local model at once and only
the CLI spends anything, so a session says which meter reports on it
(`usageProvider`, from `DriverKind::usage_provider`, which
`usage::providers_for` reads too so the two lists cannot disagree) and the
phone matches a snapshot on machine *and* provider. Nothing meters a llama
or echo session, and the phone draws **nothing** for one — not a zero, and
not "unknown". Nothing while the first fetch is out either: "checking"
under a session that turns out to meter nothing is a row the screen then
has to withdraw.
## Things that have bitten ## Things that have bitten
- **A server started with no `--tools` answers 403 at `GET /tools`, not an Project-specific only — a lesson that would bite any project on this machine
empty list.** The route is off rather than empty, so reading that as a belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
failure made "no tools" — the one setting whose entire purpose is to have
none — a session that never started. The router is always given
`--tools all` now and the choice is a filter here, so this is a trap for
whoever next changes how the server is started.
- **`POST /models/load` answers 400 for a model that is already loaded**, and
that is the *ordinary* case once one server is shared: a second session
naming a model somebody else loaded. The router driver asks what is loaded
first and treats "it is there" as the answer whatever the request said.
- **Starting a process from a blocking thread needs the runtime.** Loading a
model is minutes of disk, so it runs on a `std::thread` — and tokio's
`Command::spawn` registers the child with the reactor, so calling it with no
runtime context panics. The panic kills only that thread: the session said
`loading` for ever and nothing appeared in the log. `Routers` holds a
`tokio::runtime::Handle` and enters it around the spawn.
- **A llama session reports `loading`, and a message sent into it queues.**
Before 2026-09-19 the session showed `running` from the moment the process
started, so a minute of reading a model off disk was indistinguishable from
a minute of thinking -- and anything sent in that window came back as an
error, because `llama-server` refuses everything until the model is in
memory. `SessionStatus::Loading` is the state. A driver that reports
`Loading` owes the holding as well as the word, and **the queue is where it
holds**: held inside the turn instead (until 2026-09-20) the message was
recorded as read on arrival, so the phone drew it as sent while nothing was
reading it, and the turn then folded it out of the transcript *and*
appended it, sending it to the model twice. `Shared::await_ready` is now
only for a turn whose model was changed under it.
- **A llama turn that says nothing said something that was thrown away.** Two
silent endings were found on 2026-09-20 and both looked, on the phone, like
a message that was sent and never answered: an `{"error": ...}` chunk
arriving mid-stream on an otherwise successful response (a GPU that ran out
of memory mid-decode), and a stream that simply stops without its `[DONE]`
(the model unloaded under the session). Neither is an ordinary end, and
`generate` now fails the turn for both -- a reply that stops early is not a
reply, and the transcript keeps whatever arrived before it.
- **A transcript outlives the enum.** Removing `Event::TaskNote` hours after
adding it made every transcript that had recorded one unreadable, so
`launch` failed for those sessions and `SessionManager::new` skipped them —
no status, nothing sendable, no new messages, for every live session that
had run a background task. **The set of kinds a transcript can hold only
ever grows**: a line may come from a newer server or from an older one that
wrote a kind since dropped, and one unfamiliar word must never be able to
end the file. `Indexed::parse_at` degrades a line it cannot read to
`Event::Unreadable { kind }`, keeping its seq — which is what everything
downstream is addressed by — and the phone draws it as a placeholder saying
which kind. Never delete a variant instead of retiring it; `Event::TaskNote`
is what retiring looks like, and the phone folds it to no row.
Project-specific only. A lesson that would bite any project on this machine
belongs in `~/.claude/MACHINE.md` or the `this-machine-*` skill for its
subject; one that would bite any project anywhere belongs in the
`code-lessons` skill, under the admission test at its end.
- **tracing caches callsite interest process-wide.** A test that hits a - **tracing caches callsite interest process-wide.** A test that hits a
`tracing::warn!` with no subscriber installed can poison the interest cache `tracing::warn!` with no subscriber installed can poison the interest cache
@@ -564,19 +522,6 @@ subject; one that would bite any project anywhere belongs in the
the reader hit the end of what was loaded on every swipe and stood there the reader hit the end of what was loaded on every swipe and stood there
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
is actually on screen. is actually on screen.
- **A page landing while the history observer was fetching it must trigger its
own successor.** The observer once collected only `LazyListState.layoutInfo`;
while its collector was suspended in `loadOlderPage`, a compact page could
be composed and laid out without leaving another change to observe afterward.
Keying the effect on `oldestSeq` still missed the opening prefetch: that key
changed while `loadingHistory` was true, so the restarted effect declined to
overlap it and never noticed the flag returning to false. Codex exposes both
failures because a page full of calls collapses into one tool group: loading
stopped until expanding that group forced a layout. The observer now collects
the cursor, loading, restoring and failure state with the layout, so returning
to not-loading always rechecks the settled height. A failed page turns the
history boundary into a Try again control rather than retrying in a loop or
requiring another scroll.
- **Only `fetchTranscript` was off the main thread; the fold was not.** - **Only `fetchTranscript` was off the main thread; the fold was not.**
`foldEvent` returns a new list per event, so a page is that many copies of `foldEvent` returns a new list per event, so a page is that many copies of
a growing list — fine at 80 events and about 300,000 element copies at 800, a growing list — fine at 80 events and about 300,000 element copies at 800,
@@ -584,12 +529,38 @@ subject; one that would bite any project anywhere belongs in the
shape: the `markdownIn` scan that decides *what* to parse ran before the shape: the `markdownIn` scan that decides *what* to parse ran before the
hop to `Dispatchers.Default`. The shape to watch for is a `withContext` hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
that wraps the *fetch* and leaves the work done with the result outside it. that wraps the *fetch* and leaves the work done with the result outside it.
- **A transcript snapshot cannot survive a suspension and then be assigned.**
`loadOlderPage` joined its page to `items`, suspended while `warm` parsed ## Measurements worth not re-taking
markdown, and then assigned the joined snapshot. An SSE event arriving in
that gap appeared and vanished; reopening brought it back because the - **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
transcript and cache had it all along. Warm against a candidate if needed, emulator against a real imported transcript with the server at
then join against the current `items` and assign without another suspension. `--delay 120`. Settled and flinging fast, both into fresh history and back
Also keep the page's original `oldestSeq`: a stream reset while the fetch or through rows already drawn: **5.25.9% janky frames, 99th percentile
warm is suspended makes the page stale, and it must be discarded rather 2932ms, 02 slow UI-thread frames.** The stock Settings app on the same
than joined into the reset window. device is 3.3% and 38ms, so this is at the platform floor. The number that
is *not* at the floor is the first few seconds after opening a session,
where every row on the way is being composed for the first time; that is
inherent to a lazy list and it is why a measurement taken before the screen
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
- **The reset path is not reachable by reopening a session.** Measured
2026-09-04 against a session streaming at 20 events a second: reopening one
with an anchor 1,800 events back connects **87119 events behind**, well
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
opening page, then one span covering the whole distance. To exercise the
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
the app takes the reset on a live connection, clears, refills and carries
on without reconnecting.
- **The session screen's stream survives backgrounding here** — 20 seconds at
the launcher while 415 events were produced brought no reconnect at all,
which is not what the comment above that loop expects, and is most likely
this emulator being headless rather than the phone's behaviour.
- **Reopening a cached session costs one request for one event** (the probe),
and scrolling the whole conversation back costs nothing more; a cold open
of the same 500-event session is two pages, 100 events. Measured
2026-09-04 on the emulator against the sandbox.
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
28,000-line file because it draws one row per line; the editor is one
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
screen. If you make the editor faster, that number is what to move.
docs/EXPLORER.md's "What the measurements said" has the rest.
-1779
View File
File diff suppressed because it is too large. Load diff
+47 -195
View File
@@ -1,159 +1,68 @@
# Subagents # Subagents
A session's subagents -- helpers started by Claude Code's Task tool or Codex's A session's subagents -- the helpers a Claude Code session starts through its
collaboration tools -- each get a transcript of their own, listed in a panel Task tool -- each get a transcript of their own, listed under the session's
over the open session and readable in the same transcript view the session has. card and readable in the same transcript view the session has. Designed
Designed 2026-09-05; extended to Codex's multiplexed app-server threads on 2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`.
2026-09-13. The decisions Bryan has not yet reviewed are in `DECISIONS.md`.
## What a subagent is here ## What a subagent is here
**A subagent is a second transcript owned by a session, in the same event **A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls.** It is not a session: it cannot be model, with no process and no controls.** It is not a session: it cannot be
messaged, stopped or started, and it has no machine, model or usage of its messaged, stopped or started, and it has no setup, model or usage of its
own. Everything it shares with a session -- the transcript file format, the own. Everything it shares with a session -- the transcript file format, the
paging routes, the SSE stream, the phone's cache and rendering -- is reused paging routes, the SSE stream, the phone's cache and rendering -- is reused
by addressing, not by copying. by addressing, not by copying.
Claude reports a subagent's messages on the parent's own stream-json output, The CLI reports a subagent's messages on the parent's own stream-json
each carrying `parent_tool_use_id` = the id of the Task `tool_use` that started output, each carrying `parent_tool_use_id` = the id of the Task `tool_use`
it. Before this the translator dropped those lines that started it. Before this the translator dropped those lines
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes (`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
them to that subagent's own translator and transcript. The parent's them to that subagent's own translator and transcript. The parent's
transcript still shows only the Task call itself. transcript still shows only the Task call itself.
Codex app-server multiplexes every thread in the session tree onto the root
process's stdout. Its notifications carry `threadId`; `subAgentActivity`
items name the child thread and its lifecycle, and `collabAgentToolCall`
items carry the spawn prompt. The Codex translator routes a non-root
`threadId` exactly as Claude routes a `parent_tool_use_id`. The child thread
id is the subagent id on disk. An asynchronously delivered `agentMessage` is
a `PeerMessage`, not assistant text from the recipient. Its delta notification
does not repeat the completed item's `delivery` field, so the translator
remembers that field from `item/started` and suppresses those deltas. Letting
one into the recipient's provisional assistant row makes its next completed
message replace the combined row, visibly erasing text that Codex still has.
The parent draws the initial `spawnAgent` as its ordinary `Task` card and
closes it when the matching `subAgentActivity.started` arrives. The remaining
collaboration calls remain visible as coordination -- waiting, messaging,
listing and lifecycle controls -- rather than being mistaken for generic task
output. Null optional fields and a bare `completed` status carry no information
and are omitted; their useful result is the child transcript, status or peer
message beside them.
## Storage ## Storage
Under the session directory: Under the session directory:
``` ```
<session>/subagents/<subagent_id>/meta.json {title, created} <session>/subagents/<tool_use_id>/meta.json {title, created}
<session>/subagents/<subagent_id>/transcript.jsonl same SeqEvent lines as the session's <session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's
``` ```
The id is Claude's Task tool_use id (`toolu_…`) or Codex's child thread id. The id is the Task tool_use id (`toolu_…`), which is unique, stable across a
Both are unique, stable across a backend restart, and already the key their backend restart, and already the key everything on the parent side uses.
parent-side lifecycle uses.
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
id becomes a path. id becomes a path.
The transcript's sequence numbers are its own, starting at 1. `Transcript`, The transcript's sequence numbers are its own, starting at 1. `Transcript`,
`read_window`, `catch_up` and `read_after` work on it unchanged. `read_window`, `catch_up` and `read_after` work on it unchanged.
Its path out: deleting the session deletes its directory, subagents included, Its path out: deleting the session deletes its directory, subagents included.
and `POST /sessions/{id}/subagents/delete` removes finished ones on their own There is no separate delete.
-- all or nothing, and refused while any named one is still running, since its
transcript is still being written to and its process is the session's to stop.
## Lifecycle, as events in the subagent's transcript ## Lifecycle, as events in the subagent's transcript
1. Created when the parent Task/Agent call is seen. A current Claude CLI's 1. Created on the first child line for an unseen parent id (or, when the
`task_started` with `task_type: local_agent` is a recovery source when an parent Task call was seen, at that call). First lines written:
adopted stream begins after that call. A bare `parent_tool_use_id` is not
enough: other operations can also parent nested lines, and treating one as
proof created false subagents named after their first subcommand.
First lines written:
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when `Status Running`, then `UserMessage { text: <the Task's prompt> }` when
the prompt is known -- it genuinely is the subagent's first user turn. the prompt is known -- it genuinely is the subagent's first user turn.
2. Every child line is translated by that subagent's own `Translator` 2. Every child line is translated by that subagent's own `Translator`
(one per subagent: tool ids are unique but streaming deltas are by (one per subagent: tool ids are unique but streaming deltas are by
content-block index, and parallel subagents interleave). content-block index, and parallel subagents interleave).
3. **What ends a subagent is the CLI's own task lifecycle**, on top-level 3. **The parent's `tool_result` never finishes a subagent.** The Task tool
`system` lines that carry no `parent_tool_use_id`: `task_started` runs in the background by default: the `tool_result` -- "Async agent
(`task_id`, `tool_use_id`, `task_type`, `is_backgrounded`, the prompt), launched..." -- arrives the moment it *starts*, while the subagent goes
`task_progress` repeatedly, then `task_updated` (`patch.status`, naming the on working for however long its own turn takes, sometimes minutes. What
*task* only) and `task_notification` (`tool_use_id`, `status`, and `summary` ends it is its own turn ending: the raw API's `message_delta` on its
-- the agent's own report). `translate_task` keeps the stream carrying `stop_reason: "end_turn"` (a `stop_reason` of `tool_use`
`task_id -> tool_use_id` mapping from the first so the update can be is the model about to call one, not an end), or a `result` line for its
attributed, records the summary as the subagent's closing text, and writes own turn if a future CLI version ever sends one. Either maps to
`Status Exited`. A `Status Exited`; the subagent's vocabulary has no `Idle`, so the
`completed` update is deliberately not the end: its notification carries equivalent event `dispatch` produces for an ordinary session is dropped
the summary and would otherwise land after the ending. Any other terminal rather than written. A shipped version of this finished on the
status ends it from the update, since the failure to avoid is a subagent `tool_result` instead, which read a running background agent as
nothing ever finishes. "finished" with its transcript truncated at the moment it launched.
Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is
the authoritative level beside those edges: its set replaces the previous
set, so a missed terminal edge cannot leave a subagent running forever. Its
ids are deliberately not correlated with the edge stream; what is read off
each entry is its own description and kind, and what is read off the set is
whether it is empty and how large. The session API and stream expose that
size as `backgroundTasks`, which the phone draws beside the status, and
`GET /sessions/{id}/background` serves the entries themselves -- listed in
the session's panel *above* the subagents and never as subagent cards. An
`ambient` entry is excluded from both, on the CLI's own instruction: a
live-update watcher is not activity. A backgrounded subagent is legitimately
in both lists, since it is both running and a transcript. The edges still
carry mapping, outcome and closing summary. On adoption the driver sends a repeated `initialize`,
which makes a current CLI send the full set; an older CLI accepts it and sends no level,
leaving the edge-based path unchanged. A snapshot is reconciled immediately
when the persisted parent status proves it is between turns, and otherwise
at the next `result` boundary -- while a turn is open, a foreground agent is
legitimately absent from the background set. Reconciliation writes
`Status Exited`, which is also what makes a formerly stale row deletable;
a task notification ordered after the level can still add its summary.
The two rules this replaces were both wrong, in opposite directions. The
parent's `tool_result` is not it: a backgrounded Task's arrives at launch
("Async agent launched..."), so ending there truncated a running agent's
transcript at the moment it started. Nor is the subagent's own
`end_turn`: measured against 2.1.237 on 2026-09-06, **a subagent's lines
carry no `stream_event` at all** -- they are whole `user`/`assistant`
lines with a null `stop_reason`, no `result` line is sent for one, and the
sub's final report never appears as a child line -- so that rule could
never fire and every subagent stayed `running` for ever. `ends_a_turn` is
kept as a second detector for a dialect that does say either, and must
never be the only one again.
`Status Exited` either way; the subagent's vocabulary has no `Idle` or
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
session is dropped rather than written.
**The ending reaches the parent's transcript as nothing at all**
(2026-09-06). It was tried, and a row per finished subagent is a screenful
of dividers about work the reader was not asking after; the closing report
is *this* transcript's last line and here is where somebody reads it. What
the parent gets a row for is a message a subagent genuinely sends it, which
arrives by the peer path. A backgrounded *command* is the other half of
this and goes the other way: it has no transcript of its own, so its report
updates the tool card that launched it, which was still saying the command
was running. The two lifecycle shapes are still handled once:
whichever gets there first is the one that finds the task still open, and
`finish` below closes it. See PLAN.md's "Two turns must never be drawn as
one".
**While any task is outstanding the session's turn ends in
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
and a session with a backgrounded subagent is not doing that. The edge
fallback has two sources: the translator's `open_tasks`, and
`Subagents::any_open` -- which covers a subagent launched before a backend
restart adopted the session, whose `task_started` is behind the durable
stdout offset. On current Claude versions the replace-semantics level above
reconciles both at a safe turn boundary.
**A limit the account hits inside a subagent is hoisted to the session**
as well as recorded here, because `resume.rs` can only schedule against a
session, and a background subagent outliving its parent's turn is the
ordinary case -- see PLAN.md's "A limit a subagent hits is the session's".
4. **A child line for a subagent that already finished reopens it** 4. **A child line for a subagent that already finished reopens it**
(`Status Running`) rather than being dropped: a background Task can be (`Status Running`) rather than being dropped: a background Task can be
sent another message long after its first turn ended, and that is sent another message long after its first turn ended, and that is
@@ -161,27 +70,7 @@ transcript is still being written to and its process is the session's to stop.
`Translator`, just picking back up. `Translator`, just picking back up.
5. When the parent session's process exits (`Status Exited` on the 5. When the parent session's process exits (`Status Exited` on the
session), every subagent still `Running` gets `Status Exited` too: its session), every subagent still `Running` gets `Status Exited` too: its
process was the parent's. Read from the directory rather than from the process was the parent's.
live map, because one left `Running` by a previous run of the server is
precisely the one nothing in this process has touched -- and it would
otherwise read `running` again every time its session was started.
For Codex the same lifecycle is expressed by app-server rather than Claude's
task notices: `subAgentActivity.started` creates the child,
`subAgentActivity.interacted` reopens it, and `completed` or `interrupted`
finishes it. A child's own `turn/completed` is not its end; it remains running
until that activity edge. The root's `turn/completed` reports `waiting` while
the registry contains an open child, and the last activity completion reports
`idle` if the root is between turns. Because the child thread id is also the
on-disk id, an adopted driver can route and finish a child whose spawn record
is already behind the durable stdout offset. The registry's open count is also
Codex's `backgroundTasks` measurement: lifecycle changes send it through the
same event and session-summary fields as Claude's provider snapshot. The other
part of that measurement is app-server's runtime
`thread/backgroundTerminals/list` set. Its process ids are held only in memory
and added to the open-child count; the driver refreshes the set at terminal
boundaries and while it remains nonempty, rather than decrementing for an
unmatched ending edge.
A subagent that was mid-flight when the backend restarted keeps working: A subagent that was mid-flight when the backend restarted keeps working:
the registry reopens the existing transcript on the next child line, and the registry reopens the existing transcript on the next child line, and
@@ -191,34 +80,27 @@ the backend was down nothing recorded that until the next line arrives, so
its last status stays `Running`, which the list reports as **unknown** its last status stays `Running`, which the list reports as **unknown**
rather than as running (see the wire shape) until then. rather than as running (see the wire shape) until then.
Title: for Claude, the Task call's `description` input, then Title: the Task call's `description` input, then ` (<subagent_type>)` when
` (<subagent_type>)` when one is given; falling back to `Task` when the one is given; falling back to the tool's name when the child arrives before
description is absent. An adopted current CLI can recover the same fields from (or without) the parent call being seen.
its `local_agent` lifecycle record. For Codex, the first lifecycle record uses
the spawned thread's name or the last segment of `agentPath`, with underscores
shown as spaces, then falls back to `subagent`.
## Server layout ## Server layout
- `session/subagent.rs` -- the registry: `Subagents` (per session, in - `session/subagent.rs` -- the registry: `Subagents` (per session, in
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a `Shared`), `Subagent` (its `Transcript` behind a mutex plus a
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title, `broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk, and prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an
`delete(ids)` -- its path out. Drivers get an
`Arc<Subagents>` beside their `EventSink`; llama ignores it. `Arc<Subagents>` beside their `EventSink`; llama ignores it.
- `session/claude/translate.rs` -- routes child lines by parent id, holds - `session/claude/translate.rs` -- routes child lines by parent id, holds
one child `Translator` per subagent, remembers pending Task calls' one child `Translator` per subagent, remembers pending Task calls'
description/prompt/subagent_type. description/prompt/subagent_type.
- `session/codex/translate.rs` -- routes multiplexed app-server notifications
by thread id, remembers collaboration prompts, and translates activity
edges into the same registry lifecycle.
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1) - `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
subagents at once, each named "helper k". Each writes the prompt as its subagents at once, each named "helper k". Each writes the prompt as its
user message, streams a few words of text, runs one `Bash` tool call, then user message, streams a few words of text, runs one `Bash` tool call, then
finishes about three seconds after starting, and the parent's Task calls finishes about three seconds after starting, and the parent's Task calls
end when their subagent does. Three seconds so the running state can be end when their subagent does. Three seconds so the running state can be
seen on the phone. seen on the phone.
- `routes.rs` -- four routes, in the doc table. - `routes.rs` -- three routes, in the doc table.
## Wire shape ## Wire shape
@@ -228,23 +110,11 @@ GET /sessions same field on each row
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
POST /sessions/{id}/subagents/delete {subagents} -> 204; refused whole if one is running
``` ```
The delete is a batch rather than a `DELETE` per id for the reason the import
list's is: the phone deletes what a reader selected, and one request per row
means a batch can half-arrive, leaving the rows that were missed looking
exactly like rows nobody picked. Unlike an import delete it is local file
removal, so it is done by the time the reply is sent and there is no per-row
state to follow afterwards. What decides "running" is
`Subagents::list`'s own rule, shared through `routes::has_a_process` so the
list and the delete cannot disagree about it.
`status` is the transcript's last `Status` event, serialised like a session's `status` is the transcript's last `Status` event, serialised like a session's
(`running`, `exited`), except that a subagent whose session is not itself (`running`, `exited`), except that a subagent whose session is not itself
running cannot be running: the list answers `unknown` for that one. A running cannot be running: the list answers `unknown` for that one. The
subagent never reports `waiting`: that is a session's word for having
outstanding work of its own, and a subagent has none. The
phone words these as *running*, *finished* and *unknown* on the subcard. phone words these as *running*, *finished* and *unknown* on the subcard.
The count on `SessionInfo` is a directory listing, so the list stays cheap. The count on `SessionInfo` is a directory listing, so the list stays cheap.
@@ -252,37 +122,19 @@ The per-subagent status is only read when the list route is asked for.
## Phone ## Phone
- The subcards are ordered **still running first, then most recently - `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
active** -- a display decision made on the phone (`subagentOrder`), over the expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
server's stable oldest-first answer. Two keys rather than activity alone `Pointing.Up` -- collapsed by default. Expanding fetches
because a subagent that is thinking reports nothing meanwhile and would sink `/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
below one that just finished. indented inside the session card, the way dev-updater draws a project's
- **Holding a subcard selects it, and several at a time**, exactly as the components: title, then the status word and a relative time. The
import list works, with the selection bar drawn inside the panel rather than expansion state is per session id and survives a refresh of the list.
at the bottom of the session: this selection belongs to the subagent list, - Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
and a bar under the composer would read as acting on the conversation. **read-only** form: the same transcript, paging, cache, selection,
Delete is
*disabled*, with the reason in words, while anything selected is still
running. Deleting confirms first, dims the rows it is acting on
(`BusyItem`), and on success takes them out of the panel without refetching
anything else. The phone's cached copy of
a deleted subagent's transcript is purged with it.
- The main session list does not expand or count subagents. Swiping left over
an open session pulls an 88%-wide panel in from the right and fetches
`/sessions/{id}/subagents`; it draws one `OutlinedCard` per subagent: title,
then the status word and a relative time. The transcript remains composed
under the panel, so its event stream, draft and scroll position stay live.
Horizontal scrollers inside the transcript win the gesture. Collapsing one,
or starting over any ordinary part of the session, gives the gesture back to
the panel; Android keeps its own edge Back gesture. Swiping right on the
panel, tapping outside it, or Back closes it.
- Tapping a subcard opens a `SessionScreen` layer in **read-only** form: the
same transcript, paging, cache, selection,
images and status row, with the composer, the process button, the model images and status row, with the composer, the process button, the model
picker, the files button, the settings cog and the usage bar left out. picker, the files button, the settings cog and the usage bar left out.
The header shows the subagent's title with the session's title beneath it. The header shows the subagent's title with the session's title beneath
It is another layer over the still-composed session and its panel; Back it. Back returns to the list.
returns to the panel.
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the - Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
cache take a transcript address rather than a session id -- cache take a transcript address rather than a session id --
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a `sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
+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)
}
+39
View File
@@ -102,6 +102,16 @@ android {
targetSdk = 37 targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
// Read by MainActivity to decide, at startup, whether this is the P0 benchmark build
// (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except
// the `bench` build type below, which overrides it.
buildConfigField("boolean", "FIXTURE_MODE", "false")
}
buildFeatures {
// Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields.
buildConfig = true
// Only for the bench build type's resValue("string", "app_name", ...) below.
resValues = true
} }
packaging { packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
@@ -131,6 +141,35 @@ android {
isMinifyEnabled = false isMinifyEnabled = false
if (keystore != null) signingConfig = signingConfigs.getByName("release") if (keystore != null) signingConfig = signingConfigs.getByName("release")
} }
// P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release
// optimisations so a frame time measured here means what release means everywhere else in
// this project, its own application id so it installs beside a real enrollment rather than
// replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session
// instead of asking to be enrolled. Signed with the same key as release -- it never talks
// to a real backend, so there is no CA of its own to mismatch, and a second keystore would
// be one more secret to keep off this machine's shared mount for no benefit.
create("bench") {
initWith(getByName("release"))
// :link (wg-app-link) has no "bench" build type of its own -- it is a library shared
// with dev-updater and has no reason to know this project invented one -- so this says
// which of its build types to link against instead.
matchingFallbacks += listOf("release")
applicationIdSuffix = ".bench"
// "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents,
// Settings): this resValue overrides res/values/strings.xml's app_name for this
// build type alone, and AndroidManifest.xml's android:label reads @string/app_name
// rather than a literal so a build type can override it without touching the
// manifest.
resValue("string", "app_name", "AI Sessions bench")
buildConfigField("boolean", "FIXTURE_MODE", "true")
if (keystore != null) signingConfig = signingConfigs.getByName("release")
}
}
sourceSets {
// The fixture both bench builds (this one and iris's) open with; see
// app/bench-fixture/README.md. Read directly from its own directory rather than copied
// into androidApp/src -- one file to keep in sync with the generator, not two.
getByName("bench").assets.directories.add("../bench-fixture/assets")
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
+1 -1
View File
@@ -25,7 +25,7 @@
the fix is a judgement about how this app should look. Drop this the fix is a judgement about how this app should look. Drop this
suppression when a real icon lands. --> suppression when a real icon lands. -->
<application <application
android:label="AI Sessions" android:label="@string/app_name"
android:allowBackup="true" android:allowBackup="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar" android:theme="@android:style/Theme.Material.Light.NoActionBar"
tools:ignore="MissingApplicationIcon"> tools:ignore="MissingApplicationIcon">
File diff suppressed because it is too large. Load diff
@@ -11,7 +11,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
@@ -22,7 +21,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed import com.example.wgapplink.localNetworkAllowed
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -33,39 +31,41 @@ import kotlinx.coroutines.withContext
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them. * and the back button the only other way between them.
* *
* Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none * Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
* of them a step down from another -- and what is left here is only what genuinely is a step down: * them a step down from another -- and what is left here is only what genuinely is a step down: one
* one session, spawning one, and settings. * session, spawning one, and settings.
*/ */
private sealed class Screen { private sealed class Screen {
data object Main : Screen() /**
* The session list, with a subagent's own transcript over it when [subagent] is set.
*
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
* itself the moment its own subagent's view was closed.
*/
data class Main(val subagent: SubagentTarget? = null) : Screen()
/** /**
* One session, with the file explorer or a subagent transcript over it when set. * One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
* * SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* Both are layers on this screen rather than screens of their own, so the session under them * is opened from the session list's card rather than from inside the session it belongs to.
* stays composed: its event stream keeps flowing, its scroll position and draft stay put, and
* coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every
* return, refetching the transcript over the tunnel.
*/ */
data class Session( data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
val summary: SessionSummary,
val files: FilesTarget? = null, /**
val subagent: SubagentSummary? = null, * One session, with the file explorer over it when [files] is set.
) : Screen() *
* The explorer is a layer on this screen rather than a screen of its own, so the session under
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
* created on every return, refetching the transcript over the tunnel.
*/
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
data object Spawn : Screen() data object Spawn : Screen()
/**
* One provider on one machine: its settings, and what its shared server is holding.
*
* A step down from the machines tab rather than a tab of its own, because it is about one
* machine rather than about the backend. Addressed by ids and names rather than by the
* [Provider] it was tapped from: what it shows is fetched, and a stale copy of a card would be
* a second version of the same truth.
*/
data class ProviderSettings(val machineId: String, val provider: String) : Screen()
data object Settings : Screen() data object Settings : Screen()
} }
@@ -99,7 +99,7 @@ fun AppRoot(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.Main) } var screen by remember { mutableStateOf<Screen>(Screen.Main()) }
// A notification tap this could not follow, and why. Null both before one is asked for and // A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message. // after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) } var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
@@ -114,7 +114,7 @@ fun AppRoot(
share = shareRequest share = shareRequest
// A session already open takes it. Otherwise the list is where the choice is made, // A session already open takes it. Otherwise the list is where the choice is made,
// whatever screen was showing: Spawn and Settings have nowhere to put a file. // whatever screen was showing: Spawn and Settings have nowhere to put a file.
if (screen !is Screen.Session) screen = Screen.Main if (screen !is Screen.Session) screen = Screen.Main()
} }
} }
@@ -141,7 +141,7 @@ fun AppRoot(
existing = null, existing = null,
onSaved = { saved -> onSaved = { saved ->
settings = saved settings = saved
screen = Screen.Main screen = Screen.Main()
}, },
onBack = null, onBack = null,
) )
@@ -154,7 +154,7 @@ fun AppRoot(
// shows, so it always refetches. // shows, so it always refetches.
val goToMain = { val goToMain = {
reloadToken++ reloadToken++
screen = Screen.Main screen = Screen.Main()
} }
if (screen !is Screen.Main) { if (screen !is Screen.Main) {
BackHandler(onBack = goToMain) BackHandler(onBack = goToMain)
@@ -196,23 +196,44 @@ fun AppRoot(
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is // deliberately does not: resizing a whole screen on every frame of the keyboard animation is
// the cost that made it lag, so it moves only its composer and transcript. // the cost that made it lag, so it moves only its composer and transcript.
when (val here = screen) { when (val here = screen) {
Screen.Main -> is Screen.Main ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
MainScreen( MainScreen(
settings = current, settings = current,
reloadToken = reloadToken, reloadToken = reloadToken,
share = share, share = share,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onOpenSubagent = { summary, subagent ->
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
},
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onImported = { imported -> onImported = { imported ->
reloadToken++ reloadToken++
screen = Screen.Session(imported) screen = Screen.Session(imported)
}, },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
) )
// Its own back handler is registered after MainScreen's, so it is the one the
// platform asks first while a subagent is open -- the same rule the files
// explorer's handler follows over its session, below.
here.subagent?.let { target ->
BackHandler { screen = here.copy(subagent = null) }
// Its own opaque background: this screen was always the sole content under
// the theme's own Surface before, so it never had to paint one -- stacked over
// the list here, the space between its own cards let the list underneath show
// through without this. The same fix FilesScreen needed over its session.
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
key(target.summary.id, target.subagent.id) {
SessionScreen(
settings = current,
summary = target.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = target.subagent,
)
}
}
}
} }
is Screen.Session -> is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this // Keyed on the id, because a different session is a different screen rather than this
@@ -225,66 +246,6 @@ fun AppRoot(
// A Box so the explorer can be drawn *over* the session rather than instead of it. // A Box so the explorer can be drawn *over* the session rather than instead of it.
// No imePadding here, for the reason above -- the explorer adds its own. // No imePadding here, for the reason above -- the explorer adds its own.
Box { Box {
val fileLinkHandler = rememberFileLinkHandler { path ->
screen = here.copy(files = here.summary.filesTarget(path))
}
Box(
Modifier.then(
if (here.subagent != null || here.files != null)
Modifier.clearAndSetSemantics {}
else Modifier
)
) {
// How much background work the session has, from the one subscription
// to its events the screen below holds. Here because the panel and that
// screen both draw it, and must draw the same number.
var backgroundTasks by
remember(here.summary.id) {
mutableIntStateOf(here.summary.backgroundTasks)
}
// The two panels this session can be pulled aside for: its subagents
// from the right, and the whole main screen from the left. Both are here
// rather than screens of their own for the same reason the explorer is --
// the session under them stays composed. The main panel exists only
// inside a session, which is what makes it unswipeable until one has been
// opened.
SidePanels(
left = { active, close ->
MainPanel(
settings = current,
sessionId = here.summary.id,
active = active,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
onClose = close,
onGone = goToMain,
)
},
// The whole width: it stands in for the screen Back would have shown,
// rather than sitting over the session the way the subagents do.
leftFraction = 1f,
right = { active, close ->
SubagentPanel(
settings = current,
summary = here.summary,
active = active,
backgroundTasks = backgroundTasks,
onClose = close,
onOpenSubagent = { screen = here.copy(subagent = it) },
)
},
) {
CompositionLocalProvider(
LocalFileLinkHandler provides fileLinkHandler
) {
SessionScreen( SessionScreen(
settings = current, settings = current,
summary = here.summary, summary = here.summary,
@@ -292,27 +253,7 @@ fun AppRoot(
onFiles = { screen = here.copy(files = it) }, onFiles = { screen = here.copy(files = it) },
share = share, share = share,
onShareTaken = { share = null }, onShareTaken = { share = null },
onBackgroundTasks = { backgroundTasks = it },
) )
}
}
}
here.subagent?.let { subagent ->
BackHandler { screen = here.copy(subagent = null) }
Box(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
) {
key(subagent.id) {
SessionScreen(
settings = current,
summary = here.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = subagent,
)
}
}
}
// Its own back handler is registered after this screen's, so it is the one the // Its own back handler is registered after this screen's, so it is the one the
// platform asks first, and it steps back inside itself before closing. // platform asks first, and it steps back inside itself before closing.
here.files?.let { target -> here.files?.let { target ->
@@ -324,15 +265,6 @@ fun AppRoot(
} }
} }
} }
is Screen.ProviderSettings ->
Box(Modifier.imePadding()) {
ProviderScreen(
settings = current,
machineId = here.machineId,
provider = here.provider,
onBack = goToMain,
)
}
is Screen.Spawn -> is Screen.Spawn ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
SpawnScreen( SpawnScreen(
@@ -1,155 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* The background work a session has going, above its subagents in the panel [SidePanels] slides
* over it from the right.
*
* Collapsed to its one-line count by default, the way everything else this app adds to a screen
* arrives: what a reader came to the panel for is the subagents, and a run of cards about work
* nobody asked after would push them off it. Expanding pushes them down instead of covering them,
* so the two are read together.
*
* Nothing is drawn at all when the count is zero -- including when the provider never said, which
* is the same absence the status row draws. A permanently visible "0 bg tasks" would be a line
* about nothing on every session that has never backgrounded anything, which is most of them.
*/
fun LazyListScope.backgroundTaskSection(
count: Int,
tasks: LoadState<List<BackgroundTaskSummary>?>,
expanded: Boolean,
onToggle: () -> Unit,
onRetry: () -> Unit,
) {
if (count == 0) return
item(key = "background-heading") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(onClick = onToggle),
) {
Text(
"${backgroundTaskLabel(count)} running",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
}
if (!expanded) return
when (tasks) {
is LoadState.Loading ->
item(key = "background-loading") {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
is LoadState.Error ->
item(key = "background-error") {
Column {
Text(
tasks.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = onRetry) { Text("Try again") }
}
}
// Null is the provider declining to say, which a session whose process has gone answers.
// Said in words: the count above came from somewhere, and an empty space under it would
// read as the tasks having finished rather than as nobody being left to ask.
is LoadState.Loaded ->
when (val rows = tasks.value) {
null ->
item(key = "background-unknown") {
Text(
"This session isn't saying what these are.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
else ->
uniqueItems(rows, key = { "background-${it.id}" }) { BackgroundTaskCard(it) }
}
}
}
/**
* One background task: what it is doing, and what kind of thing is doing it.
*
* Not something to open, unlike the subagent cards below it -- a task is a provider's runtime state
* and has no transcript of its own. A backgrounded agent that does is also in the subagent list,
* under its own name.
*/
@Composable
private fun BackgroundTaskCard(task: BackgroundTaskSummary) {
val kind = backgroundTaskKindLabel(task.kind)
OutlinedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
// The kind stands in as the title where the provider gave no description, rather than
// the id it named the task by: Codex reports a process number, which says nothing to
// the person reading and would look like a name somebody chose.
Text(
task.description ?: kind,
style = MaterialTheme.typography.titleSmall,
color =
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
else LocalContentColor.current,
)
if (task.description != null) {
Spacer(Modifier.height(2.dp))
Text(
kind,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* What a [BackgroundTaskSummary.kind] is called on screen.
*
* A kind this build has not heard of is named by what every one of them has in common rather than
* by the nearest word we do know, which would be this screen asserting something the server never
* said.
*/
private fun backgroundTaskKindLabel(kind: String) =
when (kind) {
"agent" -> "subagent"
"command" -> "background command"
"workflow" -> "workflow"
else -> "background task"
}
/**
* The heading over one group in the panel, so neither list is a run of cards with no name.
*
* The same band as the background section's own heading row above, rather than a gap chosen to look
* right here: what separates a heading from the cards above it is that both headings sit in a row
* of one height.
*/
@Composable
fun PanelSectionHeading(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 48.dp)) {
Text(text, style = MaterialTheme.typography.titleMedium)
}
}
@@ -0,0 +1,108 @@
package com.example.aiapp
import android.content.Context
import java.util.concurrent.CopyOnWriteArrayList
/**
* P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process
* fake of the backend, so the `bench` build type can drive a real session screen -- the real
* [TranscriptSource], the real fold, the real paging -- with no server and no network permission.
*
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
* in this build compiles it in but never calls it, since Kotlin has no per-build-type source set
* that both [MainActivity] (which every variant compiles) and this can share without one.
*
* The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through
* ordinary `java.net.URL`, exactly as they would talk to a real server. A
* [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every
* `https://` connection to that host and answers from this object's in-memory event log instead of
* opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen,
* the fold, uniqueItems) never learns the difference.
*/
object BenchFixture {
const val FIXTURE_HOST = "bench.fixture.invalid"
const val FIXTURE_PORT = 1
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
private const val BACKLOG_COUNT = 3200
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
/** The session id every bench run opens; nothing else in this build ever mints one. */
const val SESSION_ID = "bench-fixture-session"
/**
* The whole transcript, seq order, growing as [pushLive] is called during the streaming phase.
* Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a
* live frame agree on what has "already happened" -- the same thing a real server's own
* transcript file guarantees.
*/
private val log = CopyOnWriteArrayList<Pair<String, SeqEvent>>()
/** The events not yet appended to [log] -- the streaming phase's own source. */
private var streamTail: List<Pair<String, SeqEvent>> = emptyList()
private val images = mutableMapOf<String, ByteArray>()
@Volatile private var loaded = false
/**
* Parses the bundled fixture once. Safe to call more than once; only the first does anything.
*/
@Synchronized
fun ensureLoaded(context: Context) {
if (loaded) return
val lines =
context.assets.open("transcript.jsonl").bufferedReader().readLines().filter {
it.isNotBlank()
}
val parsed = lines.map { it to parseSeqEvent(it) }
log.addAll(parsed.take(BACKLOG_COUNT))
streamTail = parsed.drop(BACKLOG_COUNT)
for (name in listOf("bench1.png", "bench2.png")) {
images[name] = context.assets.open(name).readBytes()
}
loaded = true
}
/** The events the streaming phase has left to send. */
fun remainingStreamEvents(): Int = streamTail.size
/** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */
fun pushNextLiveEvent(): Boolean {
val next = streamTail.firstOrNull() ?: return false
streamTail = streamTail.drop(1)
log.add(next)
return true
}
/** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */
@Synchronized
fun resetToBacklog(context: Context) {
loaded = false
log.clear()
ensureLoaded(context)
}
fun fileBytes(name: String): ByteArray? = images[name]
/**
* Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays.
*/
fun linesAfter(after: Long): List<String> =
log.filter { it.second.seq > after }.map { it.first }
/**
* One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores
* `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and
* what the benchmark exercises is the fold and the paging, not the server's row-joining, which
* client-core's own port tracks separately (CLIENT_CORE.md).
*/
fun page(before: Long?, limit: Int, after: Long?): List<String> {
val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L)
val candidates = log.filter {
it.second.seq < upper && (after == null || it.second.seq > after)
}
return candidates.takeLast(limit).map { it.first }
}
}
@@ -0,0 +1,181 @@
package com.example.aiapp
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLStreamHandler
import java.net.URLStreamHandlerFactory
import java.security.Principal
import java.security.cert.Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLPeerUnverifiedException
import org.json.JSONArray
/**
* Installs the process-wide interception [BenchFixture] needs. Idempotent and safe to call more
* than once; the JDK only allows [URL.setURLStreamHandlerFactory] to be called successfully once
* per process, and a second real call throws -- so this guards it rather than relying on every
* caller to remember.
*
* Scoped to [BenchFixture.FIXTURE_HOST]: any other `https://` URL falls through to the platform's
* ordinary handler, so this only ever changes behaviour for the one host the bench build invents.
*/
@Synchronized
fun installFixtureNetworkOnce() {
if (installed) return
installed = true
URL.setURLStreamHandlerFactory(
URLStreamHandlerFactory { protocol ->
if (protocol != "https") null
else
object : URLStreamHandler() {
override fun openConnection(url: URL): HttpURLConnection =
if (url.host == BenchFixture.FIXTURE_HOST) FixtureConnection(url)
else
// The bench build makes no other https call -- this factory is
// installed only in FIXTURE_MODE (MainActivity) -- so there is
// deliberately no delegate to a platform handler here: once a
// URLStreamHandlerFactory is installed there is no supported way to
// ask the JDK for its own default handler back, and re-entering this
// same factory for the fallback would recurse forever rather than
// reach one.
throw java.io.IOException(
"bench build's fixture network has no route to https host " +
"${url.host} -- only ${BenchFixture.FIXTURE_HOST} is served"
)
}
}
)
}
private var installed = false
/**
* Answers one request against [BenchFixture] instead of opening a socket. Implements just enough of
* [HttpsURLConnection] for [requestFromServer] and [Sse] to work unmodified: both only call
* `connect`/`disconnect`, set a handful of request properties they never need answered, and read
* `responseCode` and `inputStream`.
*/
private class FixtureConnection(url: URL) : HttpsURLConnection(url) {
private var input: InputStream? = null
private var writer: Thread? = null
override fun connect() {
if (input != null) return
input = route(url.path, url.query)
}
override fun disconnect() {
writer?.interrupt()
try {
input?.close()
} catch (_: IOException) {}
}
override fun usingProxy() = false
override fun getResponseCode(): Int {
connect()
return 200
}
override fun getInputStream(): InputStream {
connect()
return input!!
}
override fun getErrorStream(): InputStream? = null
// Nothing here reads any of these; implemented only because HttpsURLConnection declares them
// abstract. A fixture never negotiates real TLS, so each says exactly that rather than
// fabricating a plausible-looking certificate.
override fun getCipherSuite() = "none (bench fixture, no TLS)"
override fun getLocalCertificates(): Array<Certificate>? = null
override fun getServerCertificates(): Array<Certificate> =
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
override fun getPeerPrincipal(): Principal =
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
override fun getLocalPrincipal(): Principal? = null
/**
* [path] is `/sessions/{id}/...`; everything else this build's fixture is asked for is a bug.
*/
private fun route(path: String, query: String?): InputStream {
val params =
(query ?: "")
.split("&")
.filter { it.contains('=') }
.associate {
val (k, v) = it.split("=", limit = 2)
k to java.net.URLDecoder.decode(v, "UTF-8")
}
return when {
path.endsWith("/transcript") -> {
val lines =
BenchFixture.page(
before = params["before"]?.toLongOrNull(),
limit = params["limit"]?.toIntOrNull() ?: 80,
after = params["after"]?.toLongOrNull(),
)
val body = JSONArray(lines.map { org.json.JSONObject(it) })
ByteArrayInputStream(body.toString().toByteArray())
}
path.endsWith("/events") -> openEventsStream(params["after"]?.toLongOrNull() ?: 0L)
path.contains("/files/") -> {
val name = path.substringAfterLast("/files/")
val bytes =
BenchFixture.fileBytes(name)
?: throw IOException("bench fixture has no file named $name")
ByteArrayInputStream(bytes)
}
else -> throw IOException("bench fixture has no route for $path")
}
}
/**
* A live SSE body: [BenchFixture.linesAfter] replayed immediately, then polled every 50ms for
* anything [BenchFixture.pushNextLiveEvent] has added since -- the same shape a real backend's
* backlog-then-follow gives [Sse], just polled instead of woken, which is a fixture's business
* rather than something worth a condition variable for.
*/
private fun openEventsStream(after: Long): InputStream {
val pipeIn = PipedInputStream(1 shl 16)
val pipeOut = PipedOutputStream(pipeIn)
var sent = after
val thread = Thread {
try {
while (!Thread.currentThread().isInterrupted) {
val fresh = BenchFixture.linesAfter(sent)
for (line in fresh) {
pipeOut.write("data: $line\n\n".toByteArray())
pipeOut.flush()
sent = org.json.JSONObject(line).getLong("seq")
}
Thread.sleep(50)
}
} catch (_: InterruptedException) {
// disconnect() -- the ordinary way this ends.
} catch (_: IOException) {
// The reader side (Sse) closed its end.
} finally {
try {
pipeOut.close()
} catch (_: IOException) {}
}
}
.also {
it.isDaemon = true
it.start()
}
writer = thread
return pipeIn
}
}
@@ -0,0 +1,325 @@
package com.example.aiapp
import android.content.Context
import android.os.BatteryManager
import android.os.Process
import android.view.View
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.focus.FocusRequester
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* P0's scripted benchmark, run in-process instead of by a shell script: the phone has no usable
* system tracing (this-machine-android's skill) and no agent can drive it, so the same scroll loop
* and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace` are reproduced
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
* [BenchFixture]'s doc comment gives.
*
* **v2 (2026-09-06)**, asked for by Iris because the v1 fling was too gentle to stress-test the
* scroll path and said nothing about typing or the keyboard. Four phases now, each a slice of the
* same [FrameStats] recording ([FrameStats.markPhase]/[FrameStats.phaseLines] -- one recorder, not
* two): **fling** (real `FlingBehavior`, not `animateScrollBy`), **stream** (unchanged from v1),
* **type** (600 fixed characters into the real composer `TextFieldValue`, then deleted), and
* **keyboard** (five show/hide cycles). The exact constants below are also written into
* `docs/RUST.md`'s P0 box, "Benchmark v2 (2026-09-06)", so the iris half implements the identical
* spec -- changing a number here without updating that box makes the two apps measure different
* things while looking like the same benchmark.
*/
object BenchRun {
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, kept as the pre-v2 comparison. */
private const val CYCLES = 6
private const val SWIPE_PX = 900f
private const val SWIPE_MS = 200
private const val SWIPE_PAUSE_MS = 500L
/**
* Fling phase (v2): a real fling through the list's own [FlingBehavior], not `animateScrollBy`
* -- Iris's ask was that it "travel way faster" than the old tween-based swipe, and a tween can
* never exceed the distance it is told to cover in the time it is given, while a real fling
* decays from an initial velocity the way a finger flick does. 12,000 px/s is roughly a hard,
* fast flick on a ~420dp/in device (about 30 dp/ms-equivalent initial speed); chosen well above
* the ~4,500 px/s a moderate `animateScrollBy` swipe implies, so this phase exercises the fast
* end of what the platform's fling decay produces rather than the gentle one v1 measured.
*/
private const val FLING_VELOCITY_PX_S = 12_000f
private const val FLING_COUNT = 8
private const val FLING_SETTLE_CAP_MS = 3_000L
private const val FLING_PAUSE_MS = 300L
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
private const val STREAM_EVENTS_PER_SEC = 20
private const val STREAM_SECONDS = 20
/**
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
* own height grows over several frames, pushing the transcript above it upward the same way a
* real long message does. Exactly this string is also in `docs/RUST.md`'s P0 box so the iris
* half types the identical content.
*/
const val TYPE_TEXT =
"Benchmarking this transcript screen requires unusually long, multisyllabic words so " +
"wrapping and reflow are properly exercised: internationalization, " +
"counterproductiveness, disproportionately, incomprehensibility, " +
"deinstitutionalization, uncharacteristically, overenthusiastically, " +
"misunderstanding, straightforwardness, telecommunications, and interdisciplinary " +
"collaboration all push a narrow composer field to wrap across several lines while " +
"the transcript above is pushed upward by the growing keyboard-adjacent box, which " +
"is exactly what a real reader typing a long message sees happening now!!!"
private const val TYPE_CHAR_DELAY_MS = 50L
/**
* Keyboard phase (v2): five show/hide cycles, a second apart, is enough to see whether the
* transition is ever actually observed rather than being a one-off fluke either way.
*/
private const val KEYBOARD_CYCLES = 5
private const val KEYBOARD_SHOW_WAIT_MS = 1_000L
private const val KEYBOARD_HIDE_WAIT_MS = 1_000L
/**
* Scrolls, flings, streams, types and toggles the keyboard, then returns the extra report lines
* P0 asked for (per-phase travel/typing/keyboard counts, plus CPU time, peak RSS, battery
* current) -- [FrameStats] and [DebugStats] are reset first, exactly as `copyRenderReport`
* resets them, so the two accountings cover the same stretch of work.
*/
suspend fun run(
context: Context,
scope: CoroutineScope,
listState: LazyListState,
flingBehavior: FlingBehavior,
composerFocus: FocusRequester,
setComposerText: (String) -> Unit,
view: View,
): List<String> {
FrameStats.reset()
DebugStats.reset()
val cpuStartMs = Process.getElapsedCpuTime()
val battery = BatterySampler(context)
// Launched in the caller's scope rather than a fresh coroutineScope{} here, which would
// suspend this function until the sampler job ended -- and it only ends when told to.
val samplerJob = scope.launch {
while (isActive) {
battery.sample()
delay(1000)
}
}
val travel = runFlingPhase(listState, flingBehavior)
val sent = runStreamPhase()
runTypePhase(listState, composerFocus, setComposerText, view)
val keyboard = runKeyboardPhase(context, view)
samplerJob.cancel()
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
val rssLine = peakRssLine()
val batteryLine = battery.finish()
return listOf(
" fling: $FLING_COUNT flings out + $FLING_COUNT back at" +
" ${FLING_VELOCITY_PX_S.toInt()}px/s, travel $travel",
" scroll: $CYCLES cycles (${CYCLES * 4} swipes, legacy tween), " +
"streamed $sent/${STREAM_EVENTS_PER_SEC * STREAM_SECONDS} fixture events",
" type: ${TYPE_TEXT.length} characters inserted then deleted, one per" +
" ${TYPE_CHAR_DELAY_MS}ms",
keyboard,
" process CPU time over this run: ${cpuMs}ms",
rssLine,
batteryLine,
)
}
/**
* Phase 1: starting pinned at the newest end, [FLING_COUNT] flings away from it (toward older
* messages) through the list's real fling path, then [FLING_COUNT] back. Positive velocity here
* matches this list's existing scroll-offset convention (`TranscriptList`'s `reverseLayout`
* pins index 0 -- the newest item -- at the bottom; a positive scroll offset moves the viewport
* toward higher indices, i.e. away from the newest end and toward older content), the same sign
* the pre-v2 swipe loop below already used for its first two swipes.
*/
private suspend fun runFlingPhase(
listState: LazyListState,
flingBehavior: FlingBehavior,
): String {
FrameStats.markPhase("fling")
listState.scrollToItem(0)
val start = position(listState)
repeat(FLING_COUNT) {
listState.scroll { with(flingBehavior) { performFling(FLING_VELOCITY_PX_S) } }
waitForSettle(listState)
delay(FLING_PAUSE_MS)
}
val outward = position(listState)
repeat(FLING_COUNT) {
listState.scroll { with(flingBehavior) { performFling(-FLING_VELOCITY_PX_S) } }
waitForSettle(listState)
delay(FLING_PAUSE_MS)
}
val back = position(listState)
return "start=$start outward=$outward end=$back"
}
private fun position(listState: LazyListState) =
"idx=${listState.firstVisibleItemIndex}/off=${listState.firstVisibleItemScrollOffset}px"
/** Belt-and-suspenders on top of `performFling` already suspending until its own decay ends. */
private suspend fun waitForSettle(listState: LazyListState) {
val startedAt = System.currentTimeMillis()
while (
listState.isScrollInProgress &&
System.currentTimeMillis() - startedAt < FLING_SETTLE_CAP_MS
) {
delay(16)
}
}
/**
* Phase 2 (unchanged from v1): pinned to the newest end before streaming starts, the way
* stream-bench.sh's "Jump to latest" tap is -- a reply streamed into a list parked further back
* arrives off-screen and the report would show nothing happened.
*/
private suspend fun runStreamPhase(): Int {
FrameStats.markPhase("stream")
var sent = 0
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
BenchFixture.pushNextLiveEvent()
sent++
delay(1000L / STREAM_EVENTS_PER_SEC)
}
// Lets the last few deltas land and draw before the next phase starts.
delay(300)
return sent
}
/**
* Phase 3: focuses the real composer, shows the keyboard if the platform allows it, then types
* [TYPE_TEXT] one character at a time through the same `TextFieldValue` state a real keystroke
* updates, and deletes it the same way -- this is what exercises wrapping and the transcript
* being pushed upward, not a single big write.
*/
private suspend fun runTypePhase(
listState: LazyListState,
composerFocus: FocusRequester,
setComposerText: (String) -> Unit,
view: View,
) {
FrameStats.markPhase("type")
listState.scrollToItem(0)
composerFocus.requestFocus()
showIme(view.context, view)
// Lets focus and the keyboard's opening animation land before typing starts, so the frames
// this phase records are the wrap/reflow it is measuring, not the keyboard opening.
delay(300)
var typed = ""
for (ch in TYPE_TEXT) {
typed += ch
setComposerText(typed)
delay(TYPE_CHAR_DELAY_MS)
}
delay(200)
while (typed.isNotEmpty()) {
typed = typed.dropLast(1)
setComposerText(typed)
delay(TYPE_CHAR_DELAY_MS)
}
}
/**
* Phase 4: [KEYBOARD_CYCLES] show/hide cycles through the same [WindowInsetsControllerCompat]
* path a real IME toggle goes through, reporting how many of each were actually confirmed by
* [android.view.WindowInsets.isVisible] rather than assumed from having asked -- UI_RULES:
* never present an inferred value as a measured one. If the platform never shows it even once,
* this says so in words rather than reporting a phase with no keyboard in it.
*/
private suspend fun runKeyboardPhase(context: Context, view: View): String {
FrameStats.markPhase("keyboard")
var shown = 0
var hidden = 0
repeat(KEYBOARD_CYCLES) {
showIme(context, view)
delay(KEYBOARD_SHOW_WAIT_MS)
if (imeVisible(view)) shown++
hideIme(context, view)
delay(KEYBOARD_HIDE_WAIT_MS)
if (!imeVisible(view)) hidden++
}
return if (shown == 0) {
" keyboard: could not be shown ($KEYBOARD_CYCLES attempts, 0 confirmed visible)"
} else {
" keyboard: shown $shown/$KEYBOARD_CYCLES, hidden $hidden/$KEYBOARD_CYCLES" +
" (confirmed via isImeVisible)"
}
}
private fun controller(context: Context, view: View): WindowInsetsControllerCompat? {
val window = context.activity()?.window ?: return null
return WindowInsetsControllerCompat(window, view)
}
private fun showIme(context: Context, view: View) {
controller(context, view)?.show(WindowInsetsCompat.Type.ime())
}
private fun hideIme(context: Context, view: View) {
controller(context, view)?.hide(WindowInsetsCompat.Type.ime())
}
private fun imeVisible(view: View): Boolean =
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
private fun peakRssLine(): String {
val kb =
try {
File("/proc/self/status")
.readLines()
.firstOrNull { it.startsWith("VmHWM:") }
?.trim()
?.removePrefix("VmHWM:")
?.trim()
?.removeSuffix("kB")
?.trim()
?.toLongOrNull()
} catch (_: Exception) {
null
}
return " peak RSS: " +
(kb?.let { "${it}kB" } ?: "unavailable (/proc/self/status unreadable)")
}
}
/**
* Samples [BatteryManager.BATTERY_PROPERTY_CURRENT_NOW] (microamps) once a second for the length of
* a run. The property returns `Int.MIN_VALUE` on hardware that does not support it -- most
* emulators -- and that is reported as "unavailable" rather than folded into an average with the
* real samples, which would silently understate every number after it. See UI_RULES: never present
* an inferred value as a measured one.
*/
private class BatterySampler(context: Context) {
private val manager = context.getSystemService(BatteryManager::class.java)
private val samples = mutableListOf<Int>()
fun sample() {
val value = manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
if (value != null && value != Int.MIN_VALUE) samples.add(value)
}
fun finish(): String {
if (samples.isEmpty()) return " battery current: unavailable on this device"
val meanUa = samples.sum() / samples.size
return " battery current: mean ${meanUa}µA over ${samples.size} samples" +
" (min ${samples.min()}, max ${samples.max()})"
}
}
@@ -1,15 +1,10 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -54,49 +49,3 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
* ends that tall would bow its sides. * ends that tall would bow its sides.
*/ */
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp) val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
/**
* A round button sized to the mark it draws.
*
* The composer's three actions -- attach, stop, send -- are single glyphs, and a pill's word-shaped
* padding around one glyph was width taken from the pickers beside it: with a long model name on
* the row, the permission mode ended up too small to hit. One diameter for all three, and it is the
* platform's minimum touch target rather than a button's shorter default height.
*
* [fill] null draws the outlined form, for the one of the three that does not act on the session.
*/
@Composable
fun CircleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
fill: Color? = null,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
val sized = modifier.size(CircleButtonSize)
if (fill == null) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = sized,
) {
content()
}
} else {
Button(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
colors = actionButtonColors(fill),
contentPadding = PaddingValues(0.dp),
modifier = sized,
) {
content()
}
}
}
/** How wide and tall one of those is; see [CircleButton]. */
val CircleButtonSize = 48.dp
@@ -160,7 +160,6 @@ private val FENCE_LANGUAGES: Map<String, Language> =
"shell" to Language.SHELL, "shell" to Language.SHELL,
"zsh" to Language.SHELL, "zsh" to Language.SHELL,
"console" to Language.SHELL, "console" to Language.SHELL,
"diff" to Language.DIFF,
"python" to Language.PYTHON, "python" to Language.PYTHON,
"py" to Language.PYTHON, "py" to Language.PYTHON,
"javascript" to Language.JAVASCRIPT, "javascript" to Language.JAVASCRIPT,
@@ -60,23 +60,3 @@ fun compactingLabel(seconds: Long?): String =
seconds < 60 -> "compacting ${seconds}s" seconds < 60 -> "compacting ${seconds}s"
else -> "compacting ${seconds / 60}m ${seconds % 60}s" else -> "compacting ${seconds / 60}m ${seconds % 60}s"
} }
/**
* How full the session is, as the status row says it.
*
* Three states, not two, and the third is the one that needed the words: a session whose occupancy
* is known and whose ceiling is not. That one keeps the bare figure, and a session with a ceiling
* gets both — the reader can see which they are looking at. What must not happen is a missing
* ceiling drawn as a number, or as a proportion of some assumed window, which would be this screen
* inventing the very fact it does not have.
*
* A llama.cpp session always has one, since the window is a flag its own server was started with. A
* coding CLI's is the vendor's business and neither control protocol states it, so those keep the
* bare figure they have always had.
*/
fun contextLabel(held: Long?, limit: Long?): String =
when {
held == null -> "context unknown"
limit == null -> "context ${tokens(held)}"
else -> "context ${tokens(held)} / ${tokens(limit)}"
}
@@ -127,6 +127,20 @@ fun debugReport(
frames: List<String>, frames: List<String>,
accounting: List<String>, accounting: List<String>,
crash: String?, crash: String?,
/**
* P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on
* every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section
* heading only appears when there is something to put under it, so an ordinary copy from the
* render-report button reads exactly as it did before this existed.
*/
extra: List<String> = emptyList(),
/**
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
* [extra].
*/
phaseFrames: List<String> = emptyList(),
): String = buildString { ): String = buildString {
appendLine("ai-app render report") appendLine("ai-app render report")
appendLine(device) appendLine(device)
@@ -141,6 +155,11 @@ fun debugReport(
appendLine("transcript:") appendLine("transcript:")
transcript.forEach { appendLine(it) } transcript.forEach { appendLine(it) }
appendLine() appendLine()
if (phaseFrames.isNotEmpty()) {
appendLine("per phase:")
phaseFrames.forEach { appendLine(it) }
appendLine()
}
appendLine("frames:") appendLine("frames:")
frames.forEach { appendLine(it) } frames.forEach { appendLine(it) }
appendLine() appendLine()
@@ -152,6 +171,11 @@ fun debugReport(
appendLine("work since this was last copied:") appendLine("work since this was last copied:")
val work = DebugStats.lines() val work = DebugStats.lines()
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) } if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
if (extra.isNotEmpty()) {
appendLine()
appendLine("bench:")
extra.forEach { appendLine(it) }
}
} }
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */ /** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
@@ -40,28 +40,6 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
} }
} }
/**
* The rule between two replies that met with nothing said in between -- see
* [TranscriptItem.TurnBreak].
*
* No words and no colour. Every other divider here reports something that happened and is worth
* finding by scanning; this one only says "these are two", and it appears once per turn that
* started without anybody typing. Saying more was a screenful of announcements about background
* work the reader was not asking after -- one of them a whole shell command, drawn as centred prose
* because the words came from somewhere that had no reason to keep them short.
*
* The outline colour is the scheme's one for structure rather than for meaning, which is what this
* is. Inset from both edges so it reads as a separator between two rows rather than as the top edge
* of the one under it.
*/
@Composable
fun TurnBreakRow(modifier: Modifier = Modifier) {
HorizontalDivider(
modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 6.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
/** /**
* The mark a clear leaves. * The mark a clear leaves.
* *
@@ -61,24 +61,6 @@ sealed class SessionEvent {
data class AssistantText(val delta: String) : SessionEvent() data class AssistantText(val delta: String) : SessionEvent()
/** The durable value of the open assistant message, replacing its provisional deltas. */
data class AssistantTextFinal(val text: String) : SessionEvent()
/**
* The model's working, streamed the way its reply is: its own card, and deliberately not part
* of what the session said. Only a provider that actually streams its reasoning sends it.
*/
data class Thinking(val delta: String) : SessionEvent()
/**
* The thinking above this finished, having taken [ms].
*
* Measured by the driver, because only it can see when the model stopped: this app knows when
* an event *arrived*, and the last fragment of a block followed by a slow tool call looks
* exactly like thinking that went on that long.
*/
data class ThinkingDone(val ms: Long) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent() data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
data class ToolUpdate(val id: String, val output: String) : SessionEvent() data class ToolUpdate(val id: String, val output: String) : SessionEvent()
@@ -126,26 +108,6 @@ sealed class SessionEvent {
val turnStart: Long? = null, val turnStart: Long? = null,
) : SessionEvent() ) : SessionEvent()
/**
* A line in the transcript this build cannot read: a kind a newer server wrote, or one an older
* server wrote that has since been dropped.
*
* [kind] is the word the line called itself, so the row can say what is missing rather than
* that something is. The server makes these when reading; no driver sends one.
*/
data class Unreadable(val kind: String) : SessionEvent()
/**
* Retired on 2026-09-06, hours after it was added: a background task finishing, which turned
* out to be a screenful of notices about work nobody was asking after.
*
* Kept because a transcript is append-only -- the sessions that ran a background task in that
* window have these lines for ever. It draws no row, which is the whole reason it is still
* named here rather than left to fall through to [Unknown]: that would draw a placeholder per
* background task, which is the same wall the row was removed for.
*/
object RetiredTaskNote : SessionEvent()
/** /**
* A command the session was asked to run on itself and cannot run yet. Resolved by * A command the session was asked to run on itself and cannot run yet. Resolved by
* [CommandSent] with the same id; a command that ran straight away has only that one. * [CommandSent] with the same id; a command that ran straight away has only that one.
@@ -155,9 +117,6 @@ sealed class SessionEvent {
/** The same command, handed to the session. */ /** The same command, handed to the session. */
data class CommandSent(val id: String, val text: String) : SessionEvent() data class CommandSent(val id: String, val text: String) : SessionEvent()
/** Provider-reported number of background tasks alive now. */
data class BackgroundTasks(val count: Int) : SessionEvent()
data class Status(val state: String) : SessionEvent() data class Status(val state: String) : SessionEvent()
/** /**
@@ -176,25 +135,7 @@ sealed class SessionEvent {
* so adding turns up would report a figure the session stopped being true of. Null where the * so adding turns up would report a figure the session stopped being true of. Null where the
* dialect did not say, which leaves the context unmeasured rather than unchanged. * dialect did not say, which leaves the context unmeasured rather than unchanged.
*/ */
data class UsageDelta( data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
val tokens: Long,
val context: Long?,
/**
* How fast the reply came out, where the provider measured it -- null everywhere else,
* which is most of them. Never worked out here: the time this app watched a reply arrive
* over includes the network and whatever the server was doing between tokens.
*/
val tokensPerSecond: Double? = null,
/**
* How long the provider spent reading the prompt before it began answering; null where
* nothing measured it. The same rule as [tokensPerSecond]: the provider's own figure, or
* nothing at all.
*/
val prefillMs: Long? = null,
) : SessionEvent()
/** How much context this session's model has, which is what [UsageDelta.context] is out of. */
data class ContextWindow(val tokens: Long) : SessionEvent()
/** /**
* A compaction that finished, and how much context it recovered. * A compaction that finished, and how much context it recovered.
@@ -228,8 +169,6 @@ sealed class SessionEvent {
*/ */
data class LimitReached(val resetsAt: Double?) : SessionEvent() data class LimitReached(val resetsAt: Double?) : SessionEvent()
data class AuthenticationRequired(val message: String) : SessionEvent()
data class Error(val message: String) : SessionEvent() data class Error(val message: String) : SessionEvent()
/** /**
@@ -266,9 +205,6 @@ fun parseSeqEvent(json: String): SeqEvent {
) )
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id")) "messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta")) "assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"assistantTextFinal" -> SessionEvent.AssistantTextFinal(body.getString("text"))
"thinking" -> SessionEvent.Thinking(body.getString("delta"))
"thinkingDone" -> SessionEvent.ThinkingDone(body.getLong("ms"))
"toolStart" -> "toolStart" ->
SessionEvent.ToolStart( SessionEvent.ToolStart(
id = body.getString("id"), id = body.getString("id"),
@@ -316,25 +252,19 @@ fun parseSeqEvent(json: String): SeqEvent {
body.getString("text"), body.getString("text"),
if (body.has("turnStart")) body.getLong("turnStart") else null, if (body.has("turnStart")) body.getLong("turnStart") else null,
) )
"unreadable" -> SessionEvent.Unreadable(body.getString("kind"))
"taskNote" -> SessionEvent.RetiredTaskNote
"commandQueued" -> "commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text")) SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text")) "commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
"backgroundTasks" -> SessionEvent.BackgroundTasks(body.getInt("count"))
"status" -> SessionEvent.Status(body.getString("state")) "status" -> SessionEvent.Status(body.getString("state"))
"settings" -> "settings" ->
SessionEvent.Settings( SessionEvent.Settings(
model = body.optString("model").ifEmpty { null }, model = body.optString("model").ifEmpty { null },
permissionMode = body.optString("permissionMode").ifEmpty { null }, permissionMode = body.optString("permissionMode").ifEmpty { null },
) )
"contextWindow" -> SessionEvent.ContextWindow(body.getLong("tokens"))
"usageDelta" -> "usageDelta" ->
SessionEvent.UsageDelta( SessionEvent.UsageDelta(
body.getLong("tokens"), body.getLong("tokens"),
if (body.has("context")) body.getLong("context") else null, if (body.has("context")) body.getLong("context") else null,
if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null,
if (body.has("prefillMs")) body.getLong("prefillMs") else null,
) )
"compacted" -> "compacted" ->
SessionEvent.Compacted( SessionEvent.Compacted(
@@ -347,8 +277,6 @@ fun parseSeqEvent(json: String): SeqEvent {
SessionEvent.LimitReached( SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null if (body.has("resetsAt")) body.getDouble("resetsAt") else null
) )
"authenticationRequired" ->
SessionEvent.AuthenticationRequired(body.getString("message"))
"error" -> SessionEvent.Error(body.getString("message")) "error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type) else -> SessionEvent.Unknown(type)
} }
@@ -363,21 +291,7 @@ fun parseSeqEvent(json: String): SeqEvent {
* first time the server grows a state, and the drift would be a reply that never splits or one * first time the server grows a state, and the drift would be a reply that never splits or one
* split mid-stream. * split mid-stream.
*/ */
fun sessionWorking(state: String): Boolean = fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
state == "running" || state == "compacting" || state == "loading" || state == "reading"
/** Whether the latest events still say this session needs an explicit provider login. */
internal fun authenticationPromptAfter(open: Boolean, event: SessionEvent): Boolean =
when (event) {
is SessionEvent.AuthenticationRequired -> true
// A later provider response proves an older authentication failure in a replayed page is
// no longer current. Without this, one old failure reopened sign-in after every later
// successful turn.
is SessionEvent.AssistantText,
is SessionEvent.AssistantTextFinal,
is SessionEvent.ToolStart -> false
else -> open
}
/** /**
* The context after [event], given what it was before. * The context after [event], given what it was before.
@@ -401,18 +315,3 @@ fun contextAfter(current: Long?, event: SessionEvent): Long? =
is SessionEvent.Cleared -> null is SessionEvent.Cleared -> null
else -> current else -> current
} }
/**
* The context window after [event], mirroring the server's `context_limit_after` for the same
* reason [contextAfter] mirrors its neighbour: the screen has to keep up between page loads.
*
* A window belongs to the process, so a session whose process has exited has none — left standing,
* a session restarted on a different model would draw its occupancy against the old model's
* ceiling.
*/
fun contextLimitAfter(current: Long?, event: SessionEvent): Long? =
when (event) {
is SessionEvent.ContextWindow -> event.tokens
is SessionEvent.Status -> if (event.state == "exited") null else current
else -> current
}
@@ -44,46 +44,26 @@ import kotlinx.coroutines.withContext
/** /**
* Which machine's files to show, and where to start. * Which machine's files to show, and where to start.
* *
* A **machine**, not a session: a filesystem is a property of a machine, and a session only says * A **setup**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the machines tab -- one more * where it was working. That is what makes a second way in -- from the setups tab -- one more
* caller rather than any new code here. * caller rather than any new code here.
*/ */
data class FilesTarget( data class FilesTarget(val setup: String, val setupName: String, val start: String)
val machine: String,
val machineName: String,
val start: String,
/** A document to open immediately; [start] remains the fallback directory. */
val file: String? = null,
)
/** The explorer target for this session's machine, optionally opened on [file]. */
fun SessionSummary.filesTarget(file: String? = null) =
FilesTarget(
machine = machine,
machineName = machineName,
start = cwd?.takeIf { it.isNotBlank() } ?: "~",
file = file,
)
/** Where the explorer is: in a directory, or in one file. */ /** Where the explorer is: in a directory, or in one file. */
private sealed class Spot(val path: String) { private sealed class Spot(val path: String) {
class Dir(path: String) : Spot(path) class Dir(path: String) : Spot(path)
class Doc(path: String, val directory: Dir) : Spot(path) class Doc(path: String) : Spot(path)
}
private enum class UnsavedDestination {
Directory,
Session,
} }
/** /**
* The files on the machine a session runs on: browse them, read one, change one. * The files on the machine a session runs on: browse them, read one, change one.
* *
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps * Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing and coming back from a file costs nothing. Both back controls return from a file to its * flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
* directory. In a directory, Android back walks toward the session's project directory and closes * viewer, viewer to the directory it came from, directory to the one above -- and only closes from
* the explorer once it gets there; the header's back button closes it immediately. * where it opened.
* *
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is * Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* how one gets asked again on purpose, and creating something refetches the directory it was * how one gets asked again on purpose, and creating something refetches the directory it was
@@ -92,82 +72,51 @@ private enum class UnsavedDestination {
@Composable @Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val initialDirectory = var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
target.file?.let(::parentOf)?.let { Spot.Dir(it) } ?: Spot.Dir(target.start)
var here by
remember(target) {
mutableStateOf<Spot>(
target.file?.let { Spot.Doc(it, initialDirectory) } ?: initialDirectory
)
}
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() } val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) } var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below, // Edit mode and whether anything has been typed live here rather than in the pane below,
// because both ways out have to ask before discarding it. // because they are what back has to know about -- and back arrives from two places, the arrow
// and the platform's own gesture, which must mean the same thing.
var editing by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(false) }
var dirty by remember { mutableStateOf(false) } var dirty by remember { mutableStateOf(false) }
var unsavedDestination by remember { mutableStateOf<UnsavedDestination?>(null) } var askUnsaved by remember { mutableStateOf(false) }
val here = stack.last()
fun go(spot: Spot) { fun go(spot: Spot) {
editing = false editing = false
dirty = false dirty = false
here = spot stack = stack + spot
} }
fun leave(destination: UnsavedDestination) { fun back() {
if (editing && dirty) { when {
unsavedDestination = destination editing && dirty -> askUnsaved = true
} else if (destination == UnsavedDestination.Directory) { editing -> editing = false
go((here as Spot.Doc).directory) stack.size > 1 -> {
} else { stack = stack.dropLast(1)
onClose() editing = false
dirty = false
}
else -> onClose()
} }
} }
suspend fun load(path: String, again: Boolean) { suspend fun load(path: String, again: Boolean) {
val existing = listings[path] if (!again && listings[path] is LoadState.Loaded) return
if (!again && (existing is LoadState.Loaded || existing is LoadState.Loading)) return
listings[path] = LoadState.Loading listings[path] = LoadState.Loading
listings[path] = listings[path] =
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.machine, path)) LoadState.Loaded(fetchDir(settings, target.setup, path))
} }
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
} }
val projectDirectory = (listings[target.start] as? LoadState.Loaded)?.value?.path BackHandler(onBack = ::back)
val homeDirectory =
if (target.start == "~") projectDirectory
else (listings["~"] as? LoadState.Loaded)?.value?.path
fun systemBack() {
when (val spot = here) {
is Spot.Doc -> leave(UnsavedDestination.Directory)
is Spot.Dir -> {
val path = (listings[spot.path] as? LoadState.Loaded)?.value?.path ?: spot.path
when {
path == projectDirectory || path == target.start -> onClose()
projectDirectory != null ->
nextDirectoryToward(path, projectDirectory)?.let { go(Spot.Dir(it)) }
?: onClose()
else -> parentOf(path)?.let { go(Spot.Dir(it)) } ?: onClose()
}
}
}
}
// A file link can open without visiting the project first, but Back still needs to know where
// the project is. Home is likewise resolved by the machine rather than guessed on the phone;
// it is what lets every path beneath it be displayed with `~`, including over ssh.
LaunchedEffect(target.machine, target.start) {
if (target.file != null) load(target.start, again = false)
if (target.start != "~") load("~", again = false)
}
BackHandler(onBack = ::systemBack)
Box( Box(
Modifier.fillMaxSize() Modifier.fillMaxSize()
@@ -180,15 +129,14 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
when (val spot = here) { when (val spot = here) {
is Spot.Dir -> { is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading val state = listings[spot.path] ?: LoadState.Loading
// Navigate with the resolved path, but name anything under the machine's home // The resolved path once there is one: a directory opened as `~` is called what
// the way somebody working there would write it. // it turned out to be, not what it was asked for.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
val shownAt = tildePath(at, homeDirectory)
FilesHeader( FilesHeader(
title = baseName(shownAt), title = baseName(at),
path = shownAt, path = at,
machine = target.machineName, machine = target.setupName,
onBack = { leave(UnsavedDestination.Session) }, onBack = ::back,
) { ) {
GlyphButton( GlyphButton(
REFRESH_GLYPH, REFRESH_GLYPH,
@@ -204,7 +152,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
) )
} }
LaunchedEffect(spot.path) { load(spot.path, again = false) } LaunchedEffect(spot.path) { load(spot.path, again = false) }
DirectoryBody(state, directory = spot, onOpen = ::go) DirectoryBody(state, onOpen = ::go)
} }
is Spot.Doc -> is Spot.Doc ->
DocPane( DocPane(
@@ -213,26 +161,22 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
path = spot.path, path = spot.path,
name = baseName(spot.path), name = baseName(spot.path),
editing = editing, editing = editing,
homeDirectory = homeDirectory,
onEditing = { editing = it }, onEditing = { editing = it },
onDirty = { dirty = it }, onDirty = { dirty = it },
onBack = { leave(UnsavedDestination.Directory) }, onBack = ::back,
) )
} }
} }
} }
unsavedDestination?.let { destination -> if (askUnsaved) {
UnsavedDialog( UnsavedDialog(
onDiscard = { onDiscard = {
unsavedDestination = null askUnsaved = false
if (destination == UnsavedDestination.Directory) { editing = false
go((here as Spot.Doc).directory) dirty = false
} else {
onClose()
}
}, },
onCancel = { unsavedDestination = null }, onCancel = { askUnsaved = false },
) )
} }
@@ -241,7 +185,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
if (creating && dir != null && listing != null) { if (creating && dir != null && listing != null) {
CreateDialog( CreateDialog(
settings = settings, settings = settings,
machine = target.machine, setup = target.setup,
directory = listing.path, directory = listing.path,
onDismiss = { creating = false }, onDismiss = { creating = false },
onCreated = { path, isDirectory -> onCreated = { path, isDirectory ->
@@ -252,7 +196,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
load(dir.path, again = true) load(dir.path, again = true)
// A new file has nothing to look at, so it opens where it can be filled in. // A new file has nothing to look at, so it opens where it can be filled in.
if (!isDirectory) { if (!isDirectory) {
go(Spot.Doc(path, dir)) go(Spot.Doc(path))
editing = true editing = true
} }
} }
@@ -304,11 +248,7 @@ private fun FilesHeader(
* looks like a right one. * looks like a right one.
*/ */
@Composable @Composable
private fun ColumnScope.DirectoryBody( private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
state: LoadState<Listing>,
directory: Spot.Dir,
onOpen: (Spot) -> Unit,
) {
when (state) { when (state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp)) is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
is LoadState.Error -> is LoadState.Error ->
@@ -349,9 +289,7 @@ private fun ColumnScope.DirectoryBody(
name = entry.name, name = entry.name,
trailing = trailingOf(entry), trailing = trailingOf(entry),
onClick = { onClick = {
onOpen( onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path, directory)
)
}, },
) )
} }
@@ -419,7 +357,6 @@ private fun ColumnScope.DocPane(
path: String, path: String,
name: String, name: String,
editing: Boolean, editing: Boolean,
homeDirectory: String?,
onEditing: (Boolean) -> Unit, onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit, onDirty: (Boolean) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
@@ -444,7 +381,7 @@ private fun ColumnScope.DocPane(
state = LoadState.Loading state = LoadState.Loading
state = state =
try { try {
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.machine, path) } val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
if (got is FileContent.Text) draft = TextFieldValue(got.content) if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got) LoadState.Loaded(got)
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -467,7 +404,7 @@ private fun ColumnScope.DocPane(
try { try {
val written = val written =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
writeFile(settings, target.machine, path, draft.text, against) writeFile(settings, target.setup, path, draft.text, against)
} }
state = state =
LoadState.Loaded( LoadState.Loaded(
@@ -493,12 +430,7 @@ private fun ColumnScope.DocPane(
} }
} }
FilesHeader( FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
title = name,
path = tildePath(path, homeDirectory),
machine = target.machineName,
onBack = onBack,
) {
if (editing) { if (editing) {
if (saving) { if (saving) {
GlyphSpinner("Saving") GlyphSpinner("Saving")
@@ -595,9 +527,7 @@ private fun ColumnScope.DocPane(
scope.launch { scope.launch {
val fresh = val fresh =
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
fetchFile(settings, target.machine, path)
}
} catch (e: ApiException) { } catch (e: ApiException) {
saveError = e.message saveError = e.message
conflict = null conflict = null
@@ -641,7 +571,7 @@ private fun Note(text: String) {
@Composable @Composable
private fun CreateDialog( private fun CreateDialog(
settings: ServerSettings, settings: ServerSettings,
machine: String, setup: String,
directory: String, directory: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit, onCreated: (String, Boolean) -> Unit,
@@ -661,8 +591,8 @@ private fun CreateDialog(
scope.launch { scope.launch {
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, machine, path) if (isDirectory) createDir(settings, setup, path)
else createFile(settings, machine, path) else createFile(settings, setup, path)
} }
onCreated(path, isDirectory) onCreated(path, isDirectory)
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -748,35 +678,6 @@ internal fun parentOf(path: String): String? {
} }
} }
/**
* The next directory on the filesystem path from [current] to [destination], or null when there.
*
* Moving between two branches first walks upward to their common ancestor. Once [current] is that
* ancestor, the next press walks one segment down toward [destination]. Both paths are answers from
* the machine, so they are absolute and have no symlinks or `..` left to resolve here.
*/
internal fun nextDirectoryToward(current: String, destination: String): String? {
val here = current.trimEnd('/').ifEmpty { "/" }
val there = destination.trimEnd('/').ifEmpty { "/" }
if (here == there) return null
val beneathHere = if (here == "/") there.startsWith('/') else there.startsWith("$here/")
if (!beneathHere) return parentOf(here)
val next = there.removePrefix(here).trimStart('/').substringBefore('/')
return join(here, next)
}
/** A path as somebody on [home] writes it, leaving paths outside that home unchanged. */
internal fun tildePath(path: String, home: String?): String {
val at = path.trimEnd('/').ifEmpty { "/" }
val resolvedHome = home?.trimEnd('/')?.ifEmpty { "/" } ?: return at
return when {
at == resolvedHome -> "~"
resolvedHome != "/" && at.startsWith("$resolvedHome/") ->
"~${at.removePrefix(resolvedHome)}"
else -> at
}
}
/** What a path names: its last segment, with `/` naming itself. */ /** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String { internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/') val trimmed = path.trimEnd('/')
@@ -42,6 +42,21 @@ object FrameStats {
private val gpu = ArrayList<Long>() private val gpu = ArrayList<Long>()
private var since = System.currentTimeMillis() private var since = System.currentTimeMillis()
/**
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
*/
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
private val phaseMarks = ArrayList<PhaseMark>()
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
@Synchronized
fun markPhase(name: String) {
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
}
@Synchronized @Synchronized
fun add(metrics: FrameMetrics) { fun add(metrics: FrameMetrics) {
// The first frame after a window opens includes inflating it and is nobody's scroll. // The first frame after a window opens includes inflating it and is nobody's scroll.
@@ -69,6 +84,7 @@ object FrameStats {
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach { listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
it.clear() it.clear()
} }
phaseMarks.clear()
since = System.currentTimeMillis() since = System.currentTimeMillis()
} }
@@ -95,6 +111,38 @@ object FrameStats {
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu)) ) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
} }
/**
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
* that finished before a frame was drawn) still get a line rather than being silently dropped
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
*/
@Synchronized
fun phaseLines(refreshHz: Float): List<String> {
if (phaseMarks.isEmpty()) return emptyList()
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
val lines = ArrayList<String>()
phaseMarks.forEachIndexed { i, mark ->
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
val endMs =
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
else System.currentTimeMillis()
val samples = total.subList(mark.startIndex, endIndex)
val seconds = (endMs - mark.startMs) / 1000.0
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
if (samples.isEmpty()) {
lines += " no frames recorded in this phase"
} else {
val late = samples.count { it / 1_000_000.0 > budget }
lines += " late: $late (${percent(late, samples.size)})"
lines += " " + phase("total ", samples)
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
}
}
return lines
}
/** How long the frames recorded here spent in their draw phase, and how many there were. */ /** How long the frames recorded here spent in their draw phase, and how many there were. */
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size @Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
@@ -7,8 +7,6 @@ import androidx.compose.ui.text.buildAnnotatedString
/** What a span of code is, in the terms the palette has a colour for. */ /** What a span of code is, in the terms the palette has a colour for. */
enum class Kind { enum class Kind {
ADDITION,
DELETION,
KEYWORD, KEYWORD,
STRING, STRING,
LITERAL, LITERAL,
@@ -26,8 +24,6 @@ data class Span(val start: Int, val end: Int, val kind: Kind)
* one instance and lives with the rest of the palette. * one instance and lives with the rest of the palette.
*/ */
data class SyntaxPalette( data class SyntaxPalette(
val addition: Color,
val deletion: Color,
val keyword: Color, val keyword: Color,
val string: Color, val string: Color,
val literal: Color, val literal: Color,
@@ -38,8 +34,6 @@ data class SyntaxPalette(
) { ) {
fun of(kind: Kind): Color = fun of(kind: Kind): Color =
when (kind) { when (kind) {
Kind.ADDITION -> addition
Kind.DELETION -> deletion
Kind.KEYWORD -> keyword Kind.KEYWORD -> keyword
Kind.STRING -> string Kind.STRING -> string
Kind.LITERAL -> literal Kind.LITERAL -> literal
@@ -50,26 +44,6 @@ data class SyntaxPalette(
} }
} }
/** A unified diff is line-oriented: colour the changed lines and leave context untouched. */
fun scanDiff(code: String): List<Span> {
val spans = ArrayList<Span>()
var start = 0
while (start < code.length) {
val end = code.indexOf('\n', start).let { if (it == -1) code.length else it }
val kind =
when {
code.startsWith("+++", start) || code.startsWith("---", start) -> Kind.METADATA
code.startsWith("+", start) -> Kind.ADDITION
code.startsWith("-", start) -> Kind.DELETION
code.startsWith("@@", start) -> Kind.METADATA
else -> null
}
if (kind != null) spans.add(Span(start, end, kind))
start = if (end == code.length) end else end + 1
}
return spans
}
/** /**
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it. * [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
* *
@@ -82,8 +82,8 @@ private const val SETTLE_MS = 500L
@Composable @Composable
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) { fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var machines by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) } var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Machine?>(null) } var chosen by remember { mutableStateOf<Setup?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) } var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// What is happening to each row right now, as the word the row shows. A map keyed by id rather // What is happening to each row right now, as the word the row shows. A map keyed by id rather
@@ -100,8 +100,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows // 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. // themselves, not a flag, so the dialog can say what it is about.
var confirming by remember { mutableStateOf<List<Importable>?>(null) } var confirming by remember { mutableStateOf<List<Importable>?>(null) }
// Set from the selected Claude provider rather than repeated in the app. // Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
var permissionMode by remember { mutableStateOf("") } // 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, // 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. // so a tap reading it needs no recomposition.
val movedAt = remember { mutableMapOf<String, Long>() } val movedAt = remember { mutableMapOf<String, Long>() }
@@ -113,9 +114,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Taken from the answer rather than kept across the load: the server is what knows what is * Taken from the answer rather than kept across the load: the server is what knows what is
* running, and this screen may be opening on work another phone started. * running, and this screen may be opening on work another phone started.
*/ */
suspend fun fetchInto(machine: Machine): LoadState<List<Importable>> = suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
try { try {
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, machine.id) } val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap() running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap() rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
LoadState.Loaded(rows) LoadState.Loaded(rows)
@@ -123,10 +124,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
LoadState.Error(err.message ?: "Couldn't list sessions") LoadState.Error(err.message ?: "Couldn't list sessions")
} }
fun loadSessions(machine: Machine) { fun loadSessions(setup: Setup) {
sessions = LoadState.Loading sessions = LoadState.Loading
selected = emptySet() selected = emptySet()
scope.launch { sessions = fetchInto(machine) } scope.launch { sessions = fetchInto(setup) }
} }
/** Takes a row out of the list, once the machine no longer has it to offer. */ /** Takes a row out of the list, once the machine no longer has it to offer. */
@@ -140,9 +141,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} }
LaunchedEffect(reloadToken) { LaunchedEffect(reloadToken) {
machines = setups =
try { try {
val found = withContext(Dispatchers.IO) { fetchMachines(settings) } val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
found.firstOrNull()?.let { found.firstOrNull()?.let {
chosen = it chosen = it
loadSessions(it) loadSessions(it)
@@ -170,7 +171,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
selected = emptySet() selected = emptySet()
running = running + targets.associate { it.id to WAITING } running = running + targets.associate { it.id to WAITING }
rowErrors = rowErrors - targets.map { it.id }.toSet() rowErrors = rowErrors - targets.map { it.id }.toSet()
val machine = chosen val setup = chosen
val ids = targets.map { it.id } val ids = targets.map { it.id }
scope.launch { scope.launch {
// One request for the whole batch, not one per row. Sent row by row, a handover was // One request for the whole batch, not one per row. Sent row by row, a handover was
@@ -197,28 +198,25 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// The listing is the repair, because it carries the same state the events do. Only when // 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 // something still looks outstanding, so the ordinary case does not pay for a second
// listing, which is the most expensive call this screen makes. // listing, which is the most expensive call this screen makes.
if (machine != null && targets.any { running.containsKey(it.id) }) { if (setup != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are already // 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 // saying what is happening to them is the flicker this screen avoids everywhere
// else. // else.
sessions = fetchInto(machine) sessions = fetchInto(setup)
} }
} }
} }
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
LaunchedEffect(chosen?.id, provider?.name) {
permissionMode = provider?.defaultPermissionMode.orEmpty()
}
/** Continues [targets] in the background, leaving the screen where it is. */ /** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) { fun importAll(targets: List<Importable>) {
val machine = chosen ?: return val setup = chosen ?: return
val useProvider = provider ?: return val useProvider = provider ?: return
handOver(targets) { ids -> handOver(targets) { ids ->
startImport( startImport(
settings, settings,
machine = machine.id, setup = setup.id,
sessionIds = ids, sessionIds = ids,
provider = useProvider.name, provider = useProvider.name,
permissionMode = permissionMode, permissionMode = permissionMode,
@@ -234,7 +232,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* it, which is the case where waiting is the right thing anyway. * it, which is the case where waiting is the right thing anyway.
*/ */
fun importAndOpen(target: Importable) { fun importAndOpen(target: Importable) {
val machine = chosen ?: return val setup = chosen ?: return
val useProvider = provider ?: return val useProvider = provider ?: return
running = running + (target.id to IMPORTING) running = running + (target.id to IMPORTING)
rowErrors = rowErrors - target.id rowErrors = rowErrors - target.id
@@ -244,7 +242,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
spawnSession( spawnSession(
settings, settings,
machine = machine.id, setup = setup.id,
provider = useProvider.name, provider = useProvider.name,
// Nothing to say: the server titles it from the session it continues. // Nothing to say: the server titles it from the session it continues.
title = "", title = "",
@@ -272,10 +270,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null) java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
} }
LaunchedEffect(chosen?.id) { LaunchedEffect(chosen?.id) {
val machine = chosen?.id ?: return@LaunchedEffect val setup = chosen?.id ?: return@LaunchedEffect
try { try {
while (true) { while (true) {
val stream = ImportableStream(settings, machine) val stream = ImportableStream(settings, setup)
liveChanges.set(stream) liveChanges.set(stream)
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -343,24 +341,24 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
when (val loaded = machines) { when (val loaded = setups) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> { is LoadState.Loaded -> {
// Only worth choosing when there is a choice. // Only worth choosing when there is a choice.
if (loaded.value.size > 1) { if (loaded.value.size > 1) {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
loaded.value.forEach { machine -> loaded.value.forEach { setup ->
TextButton( TextButton(
onClick = { onClick = {
chosen = machine chosen = setup
loadSessions(machine) loadSessions(setup)
} }
) { ) {
Text( Text(
machine.name, setup.name,
color = color =
if (machine.id == chosen?.id) if (setup.id == chosen?.id)
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -377,7 +375,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} else { } else {
ChipGroup( ChipGroup(
label = "Permissions", label = "Permissions",
options = provider?.permissionModes.orEmpty(), options = PERMISSION_MODES,
selected = permissionMode, selected = permissionMode,
onSelect = { permissionMode = it }, onSelect = { permissionMode = it },
) )
@@ -441,9 +439,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
val machine = chosen ?: return@TextButton val setup = chosen ?: return@TextButton
confirming = null confirming = null
handOver(targets) { ids -> deleteImportable(settings, machine.id, ids) } handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
} }
) { ) {
// Coloured by consequence: this takes something away, wherever it appears. // Coloured by consequence: this takes something away, wherever it appears.
@@ -12,13 +12,13 @@ package com.example.aiapp
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in * the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
* the next listing. * the next listing.
*/ */
class ImportableStream(settings: ServerSettings, private val machine: String) { class ImportableStream(settings: ServerSettings, private val setup: String) {
private val stream = Sse(settings) private val stream = Sse(settings)
fun close() = stream.close() fun close() = stream.close()
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) { fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
stream.run("/machines/$machine/importable/events", onOpen) { _, data -> stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange) if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
} }
} }
@@ -16,7 +16,6 @@ enum class Language {
CPP, CPP,
CSHARP, CSHARP,
DART, DART,
DIFF,
FISH, FISH,
GO, GO,
JAVA, JAVA,
@@ -101,7 +100,7 @@ fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(la
// Lazy for the same reason [RULES] is, since it reads it. // Lazy for the same reason [RULES] is, since it reads it.
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy { private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } + RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
mapOf(Language.DIFF to ::scanDiff, Language.MARKDOWN to ::scanMarkdown) mapOf(Language.MARKDOWN to ::scanMarkdown)
} }
private val C_STYLE = BlockComment("/*", "*/", nests = false) private val C_STYLE = BlockComment("/*", "*/", nests = false)
@@ -1,404 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The models on one machine, the downloads putting more there, and HuggingFace to find them in.
*
* This was a tab of its own, about the backend's own disk. It moved under the machine's llama.cpp
* provider on 2026-09-19, when a download came to run on the machine that will serve the file:
* there is no such thing as "the models", only this machine's, and the screen that decides how a
* model is loaded is the screen that should be able to fetch one.
*
* Everything here is the machine's state rather than this screen's. A download is a process on that
* machine with its progress written beside the partial file, so closing the app, locking the phone
* or restarting the backend does not touch it, and a second device watching sees the same numbers.
*/
@Stable
class MachineModelsState(
private val settings: ServerSettings,
private val machineId: String,
private val scope: CoroutineScope,
) {
var state by mutableStateOf<LoadState<Models>>(LoadState.Loading)
private set
var query by mutableStateOf("")
var results by mutableStateOf<LoadState<List<RemoteRepo>>?>(null)
private set
var openRepo by mutableStateOf<String?>(null)
private set
var repoFiles by mutableStateOf<LoadState<List<RemoteFile>>?>(null)
private set
/** What the last action said went wrong, shown above the list that action was taken in. */
var actionError by mutableStateOf<String?>(null)
private set
val models: Models?
get() = (state as? LoadState.Loaded)?.value
val downloads: List<Download>
get() = models?.downloads.orEmpty()
/** How big each downloaded model is, by key, for the cards the provider screen draws. */
val sizes: Map<String, Long>
get() = models?.local.orEmpty().associate { it.key to it.bytes }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchMachineModels(settings, machineId))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
/** Runs [action], says what it said if it failed, and asks the machine again either way. */
private fun act(action: suspend () -> Unit) {
scope.launch {
actionError =
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
reload()
}
}
fun search() {
openRepo = null
results = LoadState.Loading
scope.launch {
results =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(searchModels(settings, query)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
fun toggleRepo(repo: String) {
if (openRepo == repo) {
openRepo = null
return
}
openRepo = repo
repoFiles = LoadState.Loading
scope.launch {
repoFiles =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchRepoFiles(settings, machineId, repo))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
fun download(repo: String, file: String) = act {
startDownload(settings, machineId, repo, file)
}
fun cancel(key: String) = act { cancelDownload(settings, machineId, key) }
fun remove(key: String) = act { deleteModel(settings, machineId, key) }
}
/**
* One machine's models, asked for again while this screen is open.
*
* Polled rather than pushed: a download belongs to a machine, not to any session, so it has no
* event stream of its own. Faster while something is downloading, because that is the only thing
* here that changes by itself -- each ask is a round trip to that machine, and once a minute would
* be a progress bar that moved in jumps.
*
* [onLocalChange] fires when the set of models on the machine changes, which is how the screen
* around this learns that a download has become a model it must now draw settings for.
*
* [enabled] is false for a provider that holds no files of its own -- the Claude CLI names its
* models rather than storing them -- and then nothing is asked of the machine at all. Taken as a
* parameter rather than decided by the caller's `if`, so that this is composed unconditionally and
* keeps its search results across the moment the provider's kind arrives.
*/
@Composable
fun rememberMachineModels(
settings: ServerSettings,
machineId: String,
enabled: Boolean,
onLocalChange: () -> Unit,
): MachineModelsState {
val scope = rememberCoroutineScope()
val state = remember(settings, machineId) { MachineModelsState(settings, machineId, scope) }
LaunchedEffect(state, enabled) {
if (!enabled) return@LaunchedEffect
var known: List<String>? = null
while (true) {
state.reload()
val local = state.models?.local?.map { it.key }
if (local != null) {
if (known != null && known != local) onLocalChange()
known = local
}
delay(if (state.downloads.any { it.state == "running" }) 1500 else 5000)
}
}
return state
}
/** What is being fetched onto this machine, above the models it already has. */
fun LazyListScope.downloadCards(state: MachineModelsState) {
uniqueItems(state.downloads, key = { "download:" + it.key }) { download ->
DownloadCard(
download = download,
onCancel = { state.cancel(download.key) },
onResume = { state.download(download.repo, download.file) },
onRemove = { state.remove(download.key) },
)
}
}
/**
* Finding a model to fetch: a search, and what it found.
*
* Below the models this machine has rather than above them, because what is here is what the reader
* came for and getting another is the rarer errand.
*/
fun LazyListScope.modelSearch(state: MachineModelsState) {
item("search") {
Spacer(Modifier.height(16.dp))
Text("Get another model", style = MaterialTheme.typography.titleSmall)
Text(
"Downloaded onto this machine, which is where llama.cpp reads it from.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
val keyboard = LocalSoftwareKeyboardController.current
OutlinedTextField(
value = state.query,
onValueChange = { state.query = it },
label = { Text("Search HuggingFace") },
singleLine = true,
// The keyboard's own key searches, and puts itself away to show what it found. The
// button below this is under the keyboard while it is up, so without this the only
// way to press it is to dismiss the keyboard first -- which nothing on screen says.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions =
KeyboardActions(
onSearch = {
keyboard?.hide()
state.search()
}
),
modifier = Modifier.fillMaxWidth(),
)
TextButton(
enabled = state.query.isNotBlank(),
onClick = {
keyboard?.hide()
state.search()
},
) {
Text("Search")
}
}
when (val found = state.results) {
null -> {}
is LoadState.Loading -> item("searching") { CircularProgressIndicator() }
is LoadState.Error ->
item("search-failed") { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
uniqueItems(found.value, key = { "repo:" + it.id }) { repo ->
val open = state.openRepo == repo.id
RepoRow(repo, expanded = open) { state.toggleRepo(repo.id) }
// 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 = state.repoFiles) {
null -> {}
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error ->
Text(files.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
Column {
val busy = state.downloads.map { it.key }.toSet()
files.value.forEach { file ->
RepoFileRow(
file,
downloading = "${repo.id}/${file.path}" in busy,
) {
state.download(repo.id, file.path)
}
}
}
}
}
}
}
}
@Composable
private fun DownloadCard(
download: Download,
onCancel: () -> Unit,
onResume: () -> Unit,
onRemove: () -> Unit,
) {
val running = download.state == "running" || download.state == "verifying"
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(download.file, style = MaterialTheme.typography.titleSmall)
Text(
download.repo,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. HuggingFace sends no size when it
// was never told one, and a bar drawn from a guess is worse than one that admits it
// is counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say
// the opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
style = MaterialTheme.typography.bodySmall,
)
} else if (running) {
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
)
}
download.error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
download.state,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
if (running) {
TextButton(onClick = onCancel) { Text("Cancel") }
} else {
// A stopped download kept its partial file, so carrying on is the cheap
// answer and starting again is not the only one offered.
TextButton(onClick = onResume) { Text("Resume") }
TextButton(onClick = onRemove) { Text("Remove") }
}
}
}
}
}
@Composable
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
"${repo.downloads} downloads · ${repo.likes} likes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
}
}
}
@Composable
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(file.path, style = MaterialTheme.typography.bodyMedium)
Text(
gigabytes(file.bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
file.have -> "Downloaded"
downloading -> "Downloading"
else -> "Download"
}
)
}
}
}
fun gigabytes(bytes: Long): String =
if (bytes >= 1_000_000_000) {
"%.2f GB".format(bytes / 1_000_000_000.0)
} else {
"%.0f MB".format(bytes / 1_000_000.0)
}
@@ -66,6 +66,35 @@ class MainActivity : ComponentActivity() {
// Transparent status bar on every version; the Surface below paints through underneath it // Transparent status bar on every version; the Surface below paints through underneath it
// and content insets itself. Same reasoning as dev-updater's MainActivity. // and content insets itself. Same reasoning as dev-updater's MainActivity.
enableEdgeToEdge() enableEdgeToEdge()
// The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session
// screen against BenchFixture's in-process fake backend, with no enrollment, no network
// permission, and no notification prompt -- none of them mean anything with no server and
// no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built
// to talk to a real backend is made to talk to this instead. Still needs the same
// status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the
// platform's own default from Android 15 on this app's targetSdk, with or without the call
// above, so skipping the padding here put the header's own buttons under the status bar --
// there to look at, but not there for `ui-trace`'s tap-by-label to land on.
if (BuildConfig.FIXTURE_MODE) {
installFixtureNetworkOnce()
BenchFixture.ensureLoaded(this)
setContent {
MaterialTheme(colorScheme = AiAppColors) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
SessionScreen(
settings = BenchFixture.settings,
summary = benchSessionSummary(),
onBack = { finish() },
onFiles = {},
)
}
}
}
}
return
}
// Dark status-bar icons only over a light background, decided from the scheme rather than // 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 // fixed. It was hardcoded to `true`, which was right against the default light surface and
// became unreadable the moment the app wore Catppuccin Mocha. // became unreadable the moment the app wore Catppuccin Mocha.
@@ -147,6 +176,33 @@ class MainActivity : ComponentActivity() {
} }
} }
/** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */
private fun benchSessionSummary() =
SessionSummary(
id = BenchFixture.SESSION_ID,
setup = "bench",
setupName = "bench",
provider = "bench",
title = "P0 benchmark",
model = null,
keepsOwnTranscript = false,
permissionMode = null,
effort = null,
takesEffort = false,
imported = false,
notify = false,
autoResume = false,
autoResumeMessage = "",
resumeAt = null,
cwd = null,
contextTokens = null,
maxImageEdge = null,
usageProvider = null,
status = "idle",
lastActivity = 0.0,
subagents = 0,
)
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open, // launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
// lands here rather than in a second activity instance. // lands here rather than in a second activity instance.
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@@ -1,53 +0,0 @@
package com.example.aiapp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
/**
* The app's root screen, in the full-width panel [SidePanels] slides over a session from the left.
*
* Not a list of its own but [MainScreen] itself, and the whole width of the screen: what a right
* swipe gets is the screen Back would have got, moved over the session instead of replacing it. The
* session stays composed underneath, with its stream open and its draft and scroll position where
* they were, so swiping the panel back off returns to it for nothing -- where Back and a tap costs
* the whole transcript over the tunnel again.
*
* Tapping the session already open is that same swipe back rather than a fresh screen: reopening it
* would hand [SessionScreen] a new summary for the conversation it is already showing.
*
* [onGone] is the one thing the list can do that this panel cannot survive -- deleting the very
* session it is drawn over. There is nothing left to swipe back into, so that closes the screen.
*/
@Composable
fun MainPanel(
settings: ServerSettings,
sessionId: String,
active: Boolean,
onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit,
onProvider: (String, String) -> Unit,
onClose: () -> Unit,
onGone: () -> Unit,
) {
// Asked again each time the panel opens: who is working and who is waiting on an answer is
// exactly what changed while the session underneath was being read.
var reloadToken by remember(sessionId) { mutableIntStateOf(0) }
LaunchedEffect(active) { if (active) reloadToken++ }
MainScreen(
settings = settings,
reloadToken = reloadToken,
onOpen = { if (it.id == sessionId) onClose() else onOpen(it) },
onSpawn = onSpawn,
onImported = onImported,
onSettings = onSettings,
onProvider = onProvider,
onDeleted = { if (it == sessionId) onGone() },
)
}
@@ -26,23 +26,19 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
/** /**
* The app's root: one title, and three views of the backend behind it. * The app's root: one title, and four views of the backend behind it.
* *
* These were screens reached by words in a row under the title, and the row was already full. Tabs * These were four screens reached by four words in a row under the title, and the row was already
* say the same thing in less space and say one more thing besides: that these are places to be * full. Tabs say the same thing in less space and say one more thing besides: that these are places
* rather than errands to run. Sessions, the machine's importable history and the machines * to be rather than errands to run. Sessions, the machine's importable history, the models on it
* themselves are all *the same backend*, looked at three ways, and none is a step down from * and the machines themselves are all *the same backend*, looked at four ways, and none is a step
* another. Settings still is, which is why it stays a pushed screen with its own Back. * down from another. Settings still is, which is why it stays a pushed screen with its own Back.
*
* Models were a fourth tab until 2026-09-19. They are a machine's models now -- downloaded onto the
* machine that has to serve them -- so they live under that machine's llama.cpp provider, beside
* the settings deciding how each one is loaded. A tab about "the models" was a claim that there is
* one such set, and there is one per machine.
*/ */
private enum class MainTab(val label: String) { private enum class MainTab(val label: String) {
Sessions("Sessions"), Sessions("Sessions"),
Import("Import"), Import("Import"),
Machines("Machines"), Models("Models"),
Setups("Setups"),
} }
@Composable @Composable
@@ -52,13 +48,11 @@ fun MainScreen(
/** What another app shared in and no session has taken yet; see [ShareRequest]. */ /** What another app shared in and no session has taken yet; see [ShareRequest]. */
share: ShareRequest? = null, share: ShareRequest? = null,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit, onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
/** One machine's provider, opened from the machines tab. */
onProvider: (String, String) -> Unit,
/** A session the list has just deleted; see [SessionListScreen]. */
onDeleted: (String) -> Unit = {},
) { ) {
var tab by remember { mutableStateOf(MainTab.Sessions) } var tab by remember { mutableStateOf(MainTab.Sessions) }
var refreshToken by remember { mutableIntStateOf(0) } var refreshToken by remember { mutableIntStateOf(0) }
@@ -147,13 +141,13 @@ fun MainScreen(
settings = settings, settings = settings,
reloadToken = token, reloadToken = token,
onOpen = onOpen, onOpen = onOpen,
onOpenSubagent = onOpenSubagent,
onSpawn = onSpawn, onSpawn = onSpawn,
onDeleted = onDeleted,
) )
MainTab.Import -> MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported) ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.Machines -> MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
MachinesScreen(settings = settings, reloadToken = token, onProvider = onProvider) MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
} }
} }
} }
@@ -32,7 +32,6 @@ import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.getUnescapedTextInNode
import com.mikepenz.markdown.utils.resolveImageAlt import com.mikepenz.markdown.utils.resolveImageAlt
import com.mikepenz.markdown.utils.resolveImageLink import com.mikepenz.markdown.utils.resolveImageLink
import java.net.URI
import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.ASTNode
@@ -90,7 +89,6 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
content.buildMarkdownAnnotatedString(node, style, settings) content.buildMarkdownAnnotatedString(node, style, settings)
} }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val fileLinkHandler = LocalFileLinkHandler.current
val onPlainTap = LocalMarkdownTap.current val onPlainTap = LocalMarkdownTap.current
val layout = remember { Ref<TextLayoutResult>() } val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour. // The renderer's own rule for a style that names no colour: the theme's text colour.
@@ -129,7 +127,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
when { when {
url != null -> { url != null -> {
up.consume() up.consume()
if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url) uriHandler.openUri(url)
} }
onPlainTap != null -> { onPlainTap != null -> {
up.consume() up.consume()
@@ -166,55 +164,6 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
*/ */
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null } val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* Opens a markdown destination inside the current session when it names a file on that session's
* machine. Null outside a session, where every link keeps its ordinary URI behaviour.
*/
val LocalFileLinkHandler = compositionLocalOf<((String) -> Boolean)?> { null }
/**
* A stable markdown link handler whose behaviour follows the latest [onFile]. Keeping its identity
* stable matters: every visible markdown paragraph reads it, and a session recomposes on every
* streamed event.
*/
@Composable
fun rememberFileLinkHandler(onFile: (String) -> Unit): (String) -> Boolean {
val latest = rememberUpdatedState(onFile)
return remember {
{ destination ->
val path = filePathOf(destination)
if (path == null) false
else {
latest.value(path)
true
}
}
}
}
/**
* The path named by a local-file markdown destination.
*
* Only absolute paths and local `file:` URIs are claimed. A relative destination might be a web
* link, and sending one to a machine's filesystem would silently give an ordinary link a different
* meaning. Editors commonly append a line and optional column; the current viewer opens the file
* itself, so those coordinates are removed here.
*/
internal fun filePathOf(destination: String): String? {
val uri = runCatching { URI(destination) }.getOrNull()
val path =
when {
destination.startsWith("/") && !destination.startsWith("//") ->
uri?.path ?: destination.substringBefore('#').substringBefore('?')
uri != null &&
uri.scheme.equals("file", ignoreCase = true) &&
(uri.host.isNullOrEmpty() || uri.host == "localhost") -> uri.path
else -> null
}
if (path.isNullOrEmpty() || !path.startsWith('/')) return null
return path.replace(Regex(":\\d+(?::\\d+)?$"), "")
}
/** /**
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the * [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 * behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
@@ -21,25 +21,12 @@ const val DEFAULT_MODEL = "default"
* one model rather than one model from another. Anything that does not look like that is returned * one model rather than one model from another. Anything that does not look like that is returned
* untouched. * untouched.
* *
* A llama.cpp session's model is not an identifier at all -- it is `owner/repo/file.gguf`, where
* the file was downloaded from -- so what is kept is the file, which is the part that tells two
* models apart, and the extension goes with the directories. The model's *own* name is better still
* and is not derivable here: it is inside the file, and only the server has ever opened it. Where a
* screen has the server's answer it should prefer it; this is the floor under every screen that
* does not.
*
* A display decision, not a correction: the full name is what the session reports. * A display decision, not a correction: the full name is what the session reports.
*/ */
fun modelLabel(model: String?): String { fun modelLabel(model: String?): String {
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
if (name.endsWith(GGUF)) {
return name.substringAfterLast('/').removeSuffix(GGUF)
}
return name.removePrefix("claude-").replace(DATED_SUFFIX, "") return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
} }
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */ /** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
private val DATED_SUFFIX = Regex("""-\d{8}$""") private val DATED_SUFFIX = Regex("""-\d{8}$""")
/** What every model a llama.cpp session can run is stored as. */
private const val GGUF = ".gguf"
@@ -0,0 +1,374 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Models on the backend, and HuggingFace to get more from.
*
* Everything here is the server's state rather than this screen's: what is downloaded, and what is
* downloading, are the same answers on every enrolled device, and a download started here keeps
* going when this screen closes.
*/
@Composable
fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) }
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<LoadState<List<RemoteRepo>>?>(null) }
var openRepo by remember { mutableStateOf<String?>(null) }
var repoFiles by remember { mutableStateOf<LoadState<List<RemoteFile>>?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchModels(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// Polled rather than pushed: a download belongs to the machine, not to any session, so it has
// no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the
// loop with a read now rather than leaving the reader watching for a second and a half.
LaunchedEffect(reloadToken) {
while (true) {
reload()
delay(1500)
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search HuggingFace") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
TextButton(
enabled = query.isNotBlank(),
onClick = {
openRepo = null
results = LoadState.Loading
scope.launch {
results =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(searchModels(settings, query))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
},
) {
Text("Search")
}
Spacer(Modifier.height(8.dp))
LazyColumn(Modifier.fillMaxSize()) {
when (val current = state) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(current.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded -> {
if (current.value.downloads.isNotEmpty()) {
item { SectionLabel("Downloading") }
uniqueItems(current.value.downloads, key = { it.key + it.run }) { download
->
DownloadCard(download) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
cancelDownload(settings, download.key)
}
}
.exceptionOrNull()
?.message
}
}
}
}
item { SectionLabel("On the backend") }
if (current.value.local.isEmpty()) {
item {
Text(
"None yet. Search above to find one.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
uniqueItems(current.value.local, key = { it.key }) { model ->
LocalModelCard(model) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
deleteModel(settings, model.key)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
results?.let { found ->
item { SectionLabel("HuggingFace") }
when (found) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
uniqueItems(found.value, key = { it.id }) { repo ->
val open = openRepo == repo.id
RepoRow(repo, expanded = open) {
if (open) {
openRepo = null
} else {
openRepo = repo.id
repoFiles = LoadState.Loading
scope.launch {
repoFiles =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(
fetchRepoFiles(settings, repo.id)
)
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
}
// Inside the expanded repository's own item rather than as a section
// after the list: drawn after every card, a repository's files read as
// belonging to whichever card happened to be last.
if (open) {
when (val files = repoFiles) {
null -> {}
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error ->
Text(files.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
Column {
val busy =
(state as? LoadState.Loaded)
?.value
?.downloads
.orEmpty()
.filter { it.state == "running" }
.map { it.key }
.toSet()
files.value.forEach { file ->
RepoFileRow(
file,
downloading = "${repo.id}/${file.path}" in busy,
) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
startDownload(
settings,
repo.id,
file.path,
)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
}
}
}
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Spacer(Modifier.height(12.dp))
Text(text, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun DownloadCard(download: Download, onCancel: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(download.file, style = MaterialTheme.typography.titleSmall)
Text(
download.repo,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. The server sends no total when it was
// never told one, and a bar drawn from a guess is worse than one that admits it is
// counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say the
// opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
style = MaterialTheme.typography.bodySmall,
)
} else {
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
)
}
download.error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row {
Text(
download.state,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
if (download.state == "running") {
TextButton(onClick = onCancel) { Text("Cancel") }
}
}
}
}
}
@Composable
private fun LocalModelCard(model: LocalModel, onDelete: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(model.file, style = MaterialTheme.typography.titleSmall)
Text(
"${model.repo} · ${gigabytes(model.bytes)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onDelete) { Text("Delete") }
}
}
}
@Composable
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
"${repo.downloads} downloads · ${repo.likes} likes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
}
}
}
@Composable
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(file.path, style = MaterialTheme.typography.bodyMedium)
Text(
gigabytes(file.bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
file.have -> "Downloaded"
downloading -> "Downloading"
else -> "Download"
}
)
}
}
}
private fun gigabytes(bytes: Long): String =
if (bytes >= 1_000_000_000) {
"%.2f GB".format(bytes / 1_000_000_000.0)
} else {
"%.0f MB".format(bytes / 1_000_000.0)
}
@@ -28,7 +28,7 @@ import androidx.compose.ui.unit.sp
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the * This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
* grounds that a system font may not have the glyph. That objection is about *relying* on a system * 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 * 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 -- eighteen glyphs, 2.9 KB, subset out of the 3 MB symbols * 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 * 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 script did not subset is a glyph that silently isn't there.
* *
@@ -136,16 +136,6 @@ val EDIT_GLYPH = glyph(0xF03EB)
*/ */
val SAVE_GLYPH = glyph(0xF0193) val SAVE_GLYPH = glyph(0xF0193)
/**
* `md-menu` -- the burger: three stacked rules, drawn as the handle a row is dragged by.
*
* The mark for "take hold of this and move it" rather than for a menu, which is what it means on a
* row that has one: three rules look like the rows of a list, and the only thing here that draws
* them is a list being rearranged. Nothing else in this app opens a menu from a burger, so the two
* senses cannot be confused.
*/
val DRAG_GLYPH = glyph(0xF035C)
/** /**
* The size an icon draws at beside a line of text. * The size an icon draws at beside a line of text.
* *
@@ -175,7 +165,7 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
* own corners and beside a title it arrived at the first letter. And it is taller than any header'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. * text, which is what lets the button fill a header row rather than sit in the middle of one.
*/ */
val GLYPH_BUTTON_SIZE = 48.dp 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 * The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
@@ -34,8 +34,6 @@ import org.json.JSONObject
* gets a push from Google's servers, which would mean this backend talking to Google about * 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. * somebody's coding sessions, and the whole point of the tunnel is that it does not.
* *
* Every moment it hears about goes to the drawer; [show] decides what else is done with it.
*
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as * 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 * 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 * no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
@@ -140,11 +138,10 @@ class NotificationService : Service() {
// Nothing to tell somebody about the session they are reading. The transcript in front of // Nothing to tell somebody about the session they are reading. The transcript in front of
// them is already saying it. // them is already saying it.
if (isOnScreen(notification.sessionId)) return if (isOnScreen(notification.sessionId)) return
// The app is up, so it says this itself as a banner over whatever screen they are on -- // The app is up: it says this itself, as a banner over whatever screen they are on. Never
// which interrupts, where the drawer's row records: a banner lasts seconds and reaches only // both -- one thing happened, and a drawer filling up behind an app that already showed you
// somebody already looking. Both go up, and the banner having done the interrupting is what // each one is a drawer nobody reads.
// makes the row a silent one. if (handOver(notification)) return
val banner = handOver(notification)
val manager = NotificationManagerCompat.from(this) val manager = NotificationManagerCompat.from(this)
// Two different noes, and both are answers rather than faults: the runtime permission // 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. // refused, and notifications switched off for the app in Android's own settings.
@@ -175,7 +172,6 @@ class NotificationService : Service() {
.setAutoCancel(true) .setAutoCancel(true)
.setWhen((notification.at * 1000).toLong()) .setWhen((notification.at * 1000).toLong())
.setShowWhen(true) .setShowWhen(true)
.setSilent(banner)
.build() .build()
manager.notify(notification.sessionId, ALERT_ID, built) manager.notify(notification.sessionId, ALERT_ID, built)
} }
@@ -266,8 +262,7 @@ class NotificationService : Service() {
* Whether there is an app to reach is the subscriber count rather than a flag of its own: * Whether there is an app to reach is the subscriber count rather than a flag of its own:
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends * [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 * 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. Reaching the app * sessions finishing together all land rather than the last one winning.
* does not stop the drawer's row; it makes it a silent one.
*/ */
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8) private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
@@ -277,11 +272,7 @@ class NotificationService : Service() {
private fun handOver(notification: SessionNotification) = private fun handOver(notification: SessionNotification) =
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification) toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
/** /** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
* Somebody is looking at [sessionId]; nothing is posted about it until they stop, and
* whatever the drawer is already holding about it goes now rather than waiting to be swiped
* away. Opening the session *is* reading the notification, whichever way they got here.
*/
fun showing(context: Context, sessionId: String) { fun showing(context: Context, sessionId: String) {
onScreen = sessionId onScreen = sessionId
// Whatever was posted about it before is about to be read, so it has nothing left to // Whatever was posted about it before is about to be read, so it has nothing left to
@@ -1,121 +0,0 @@
package com.example.aiapp
import android.content.Context
import androidx.core.content.edit
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
private const val PENDING_MESSAGES = "pending-messages"
/** A quiet user bubble below the durable transcript. */
internal data class QueuedMessage(
val id: String,
val text: String,
val attachments: List<String>,
val refusal: String? = null,
/** This phone is still waiting for any durable event that says the server accepted it. */
val local: Boolean = false,
/** The HTTP request returned successfully; the provider event is still outstanding. */
val serverAccepted: Boolean = false,
)
internal fun localPendingMessage(text: String, attachments: List<String>) =
QueuedMessage("local-${UUID.randomUUID()}", text, attachments, local = true)
private fun QueuedMessage.matches(text: String, attachments: List<String>) =
this.text == text && this.attachments == attachments
/** Replaces the local bridge with the server's durable waiting message, without drawing both. */
internal fun reconcileQueuedMessage(
queued: List<QueuedMessage>,
event: SessionEvent.MessageQueued,
): List<QueuedMessage> {
if (queued.any { !it.local && it.id == event.id }) return queued
val at = queued.indexOfFirst { it.local && it.matches(event.text, event.attachments) }
if (at < 0) return queued + QueuedMessage(event.id, event.text, event.attachments)
return queued.mapIndexed { index, message ->
if (index == at) QueuedMessage(event.id, event.text, event.attachments) else message
}
}
/** Removes exactly the pending bubble that became a provider-received user message. */
internal fun reconcileUserMessage(
queued: List<QueuedMessage>,
event: SessionEvent.UserMessage,
): List<QueuedMessage> {
val at =
event.id?.let { id -> queued.indexOfFirst { !it.local && it.id == id }.takeIf { it >= 0 } }
?: queued.indexOfFirst { it.local && it.matches(event.text, event.attachments) }
return if (at < 0) queued else queued.filterIndexed { index, _ -> index != at }
}
/** Keeps a failed send in place and puts its actionable failure in that message's bubble. */
internal fun markPendingFailure(
queued: List<QueuedMessage>,
id: String,
failure: String,
): List<QueuedMessage> = queued.map { message ->
if (message.local && message.id == id) message.copy(refusal = failure) else message
}
/** Stops persisting a send once the server owns it, while its bubble awaits the provider event. */
internal fun markPendingAccepted(queued: List<QueuedMessage>, id: String): List<QueuedMessage> =
queued.map { message ->
if (message.local && message.id == id) message.copy(serverAccepted = true) else message
}
internal fun discardPendingMessage(
queued: List<QueuedMessage>,
id: String,
): List<QueuedMessage> = queued.filterNot { it.local && it.id == id }
/** Restores sends for which this phone has not yet seen a durable server event. */
internal fun loadPendingMessages(context: Context, key: String): List<QueuedMessage> {
val encoded =
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).getString(key, null)
?: return emptyList()
return try {
val messages = JSONArray(encoded)
List(messages.length()) { index ->
val message = messages.getJSONObject(index)
val attachments = message.optJSONArray("attachments") ?: JSONArray()
QueuedMessage(
id = message.getString("id"),
text = message.getString("text"),
attachments = List(attachments.length()) { attachments.getString(it) },
refusal = message.optString("refusal").takeIf { it.isNotEmpty() },
local = true,
)
}
} catch (_: org.json.JSONException) {
// A corrupt local outbox is not useful on the next open either. Remove it rather than
// repeatedly pretending it decoded to an intentionally empty one.
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).edit { remove(key) }
emptyList()
}
}
/** Stores only sends the server has not confirmed; everything accepted is the server's to keep. */
internal fun savePendingMessages(context: Context, key: String, queued: List<QueuedMessage>) {
val local = queued.filter { it.local && !it.serverAccepted }
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).edit {
if (local.isEmpty()) {
remove(key)
} else {
putString(
key,
JSONArray(
local.map { message ->
JSONObject()
.put("id", message.id)
.put("text", message.text)
.put("attachments", JSONArray(message.attachments))
.put("refusal", message.refusal ?: "")
}
)
.toString(),
)
}
}
}
@@ -1,215 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Relays a provider CLI's headless browser login without ever owning its credentials.
*
* The URL and code live only in this composition. The CLI process on [machineId] remains the one
* OAuth client and the only writer of its credential file.
*/
@Composable
fun ProviderLoginDialog(
settings: ServerSettings,
machineId: String,
machineName: String,
provider: String,
onDismiss: () -> Unit,
onSignedIn: () -> Unit,
) {
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
var login by remember(machineId, provider) { mutableStateOf<ProviderLogin?>(null) }
var code by remember(machineId, provider) { mutableStateOf("") }
var error by remember(machineId, provider) { mutableStateOf<String?>(null) }
var retry by remember(machineId, provider) { mutableIntStateOf(0) }
suspend fun follow(initial: ProviderLogin): ProviderLogin {
var current = initial
val wasSubmitting = initial.state == "submitting"
while (current.state == "starting" || current.state == "submitting") {
delay(400)
current =
withContext(Dispatchers.IO) {
fetchProviderLogin(
settings,
machineId,
provider,
current.attempt,
)
}
login = current
}
if (wasSubmitting && current.state == "waitingForCode" && current.detail == null) {
current =
current.copy(
detail = "That code was not accepted. Copy the complete code and try again."
)
login = current
}
return current
}
LaunchedEffect(machineId, provider, retry) {
error = null
code = ""
login = null
try {
val started =
withContext(Dispatchers.IO) { startProviderLogin(settings, machineId, provider) }
login = started
if (follow(started).state == "succeeded") {
onSignedIn()
}
} catch (e: ApiException) {
error = e.message
}
}
fun dismiss() {
login
?.takeUnless { it.state in setOf("succeeded", "failed", "cancelled") }
?.let {
scope.launch(Dispatchers.IO) {
runCatching { cancelProviderLogin(settings, machineId, provider, it.attempt) }
}
}
onDismiss()
}
AlertDialog(
onDismissRequest = ::dismiss,
title = { Text("Sign in to Claude") },
text = {
Column {
Text(
"Claude will sign in on $machineName. Open the authorization page, then " +
"paste the code it gives you here."
)
Spacer(Modifier.height(12.dp))
when (val current = login) {
null ->
if (error == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Text("Starting sign-in…")
}
}
else ->
when (current.state) {
"starting",
"submitting" ->
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Text(
if (current.state == "submitting") "Checking code…"
else "Starting sign-in…"
)
}
"waitingForCode" -> {
TextButton(
onClick = {
runCatching {
current.authorizationUrl?.let(uriHandler::openUri)
}
.onFailure {
error = "Couldn't open the authorization page."
}
},
enabled = current.authorizationUrl != null,
) {
Text("Open authorization page")
}
OutlinedTextField(
value = code,
onValueChange = { code = it },
label = { Text("Authorization code") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
current.detail?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
}
"succeeded" -> Text("Signed in on $machineName.")
"cancelled" -> Text("Sign-in was cancelled.")
else ->
Text(
current.detail ?: "Sign-in failed.",
color = MaterialTheme.colorScheme.error,
)
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
val current = login
when {
current?.state == "waitingForCode" ->
TextButton(
onClick = {
scope.launch {
error = null
try {
val submitted =
withContext(Dispatchers.IO) {
submitProviderLoginCode(
settings,
machineId,
provider,
current.attempt,
code,
)
}
login = submitted
if (follow(submitted).state == "succeeded") {
onSignedIn()
}
} catch (e: ApiException) {
error = e.message
}
}
},
enabled = code.isNotBlank(),
) {
Text("Continue")
}
error != null || current?.state == "failed" || current?.state == "cancelled" ->
TextButton(onClick = { retry++ }) { Text("Try again") }
current?.state == "succeeded" -> TextButton(onClick = onDismiss) { Text("Done") }
}
},
dismissButton = {
if (login?.state != "succeeded") {
TextButton(onClick = ::dismiss) { Text("Cancel") }
}
},
)
}
@@ -1,117 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* The controls for whatever settings a provider says it takes.
*
* One composable for both screens that offer them — the spawn form and the session settings dialog
* — and for every provider, because the server declares the list (see `DriverKind::params`) rather
* than this file knowing it. A driver that grows a setting gets a control here with no change to
* the app, which is the whole point: the values that suit one machine ship as defaults, and every
* one of them stays reachable from a phone.
*
* [values] is the whole map and [onChange] hands back the whole map. A key absent from it means the
* setting is unset, which is what every [ParamSpec.unset] describes — so clearing a field and never
* touching it are deliberately the same state.
*/
@Composable
fun ProviderParamFields(
specs: List<ParamSpec>,
values: Map<String, String>,
onChange: (Map<String, String>) -> Unit,
/**
* Whether to say which settings wait for a restart. False on a spawn form, where nothing is
* running yet and every setting is about to be read — saying it there would be a warning about
* a state the reader cannot be in.
*/
warnAboutRestart: Boolean,
modifier: Modifier = Modifier,
) {
if (specs.isEmpty()) return
Column(modifier.fillMaxWidth()) {
specs.forEach { spec ->
val set = { value: String ->
onChange(
// Blank clears rather than storing an empty string: the server reads an absent
// key as "use the default", and an empty one would be a value it then failed
// to parse.
if (value.isBlank()) values - spec.key else values + (spec.key to value)
)
}
when (spec.kind) {
"choice" -> {
// The first option is what unset means, so selecting it clears the key — see
// `ParamKind::Choice`. Without that the picker could show a default it could
// not return to.
val default = spec.options.firstOrNull().orEmpty()
ChipGroup(
label = spec.label + restartSuffix(spec, warnAboutRestart),
options = spec.options,
selected = values[spec.key] ?: default,
onSelect = { chosen -> set(if (chosen == default) "" else chosen) },
)
}
else ->
OutlinedTextField(
value = values[spec.key].orEmpty(),
onValueChange = set,
label = { Text(spec.label + restartSuffix(spec, warnAboutRestart)) },
placeholder = { Text(spec.unset) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = keyboardFor(spec.kind)),
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(16.dp))
}
if (warnAboutRestart && specs.any { it.restart }) {
Text(
"A setting marked “on restart” is saved now and read when this session's process " +
"next starts.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* Marks a control whose value will not take effect yet.
*
* On the label rather than beside it, because the reader decides whether to change the thing before
* they touch it — a note underneath is read after the decision.
*/
private fun restartSuffix(spec: ParamSpec, warn: Boolean): String =
if (warn && spec.restart) " (on restart)" else ""
/**
* The keyboard for a value's shape. A number field that opens the letter keyboard is one every
* entry is made harder by, and these are nearly all numbers.
*/
private fun keyboardFor(kind: String): KeyboardType =
when (kind) {
"integer" -> KeyboardType.Number
"decimal" -> KeyboardType.Decimal
else -> KeyboardType.Text
}
/**
* How long typing has to stop before edited settings are sent.
*
* Long enough that a number is one request rather than one per digit, short enough that closing the
* dialog straight after typing still saves — the save runs on the screen behind it, which outlives
* the dialog, so this delay is not a window the value can be lost in.
*/
const val PARAM_SAVE_DELAY_MS = 700L
@@ -1,484 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* One provider on one machine: what it is, what its shared server is holding, and how each of its
* models is loaded.
*
* This is where a setting that belongs to a *machine* lives, as opposed to one that belongs to a
* session. The two were one list until llama.cpp sessions came to share one server per machine: how
* a model is loaded stopped being anything a single session could decide, because one copy of it in
* memory is what several sessions are talking to.
*
* It is also the only place a loaded model is taken out of memory. Nothing does that on its own —
* closing a session leaves the model loaded on purpose, since the next one to want it would
* otherwise pay the load again — so the memory is freed here, where what it costs everybody is
* visible.
*/
@Composable
fun ProviderScreen(
settings: ServerSettings,
machineId: String,
provider: String,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<ProviderView>>(LoadState.Loading) }
var reload by remember { mutableIntStateOf(0) }
var editing by remember { mutableStateOf<ProviderModel?>(null) }
var confirmingStop by remember { mutableStateOf(false) }
// What is being done to the server or to one of its models, in a word, and what went wrong
// when it did. Both here rather than per row: these act on the whole machine.
var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
var confirmingDelete by remember { mutableStateOf<ProviderModel?>(null) }
// The machine's own models and what is being fetched onto it. Only for a provider that serves
// files off that machine's disk -- everything else names its models rather than holding them,
// and a search for a GGUF under the Claude CLI would be an offer that leads nowhere.
val kind = (state as? LoadState.Loaded)?.value?.kind
val machineModels =
rememberMachineModels(
settings = settings,
machineId = machineId,
enabled = kind == "llama_cpp",
// A download that became a model is a model this screen has no settings for yet, so
// the view it is drawing is now one model short of the truth.
onLocalChange = { reload++ },
)
LaunchedEffect(reload) {
state =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchProvider(settings, machineId, provider))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// Say what is happening, do it, say what went wrong, refetch: every action on this screen
// changes what it is showing.
val act = { what: String, action: suspend () -> Unit ->
scope.launch {
busy = what
actionError =
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
busy = null
reload++
}
Unit
}
// The models search at the bottom takes the keyboard, and everything below the field it is
// typed in -- the Search button, the results -- is behind it without this.
Column(Modifier.fillMaxSize().imePadding().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = onBack) { Text("Back") }
}
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> {
val view = current.value
Text(view.name, style = MaterialTheme.typography.titleMedium)
Text(
"on ${view.machine}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
view.command?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(12.dp))
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
busy?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
Text(it, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
}
LazyColumn(Modifier.fillMaxSize()) {
view.server?.let { server ->
item("server") {
ServerCard(
server = server,
maxLoaded = view.maxLoaded,
enabled = busy == null,
onStop = { confirmingStop = true },
onMaxLoaded = { chosen ->
act("Saving…") {
setProviderSettings(
settings,
machineId,
provider,
chosen,
)
}
},
)
Spacer(Modifier.height(12.dp))
}
}
if (view.models.isNotEmpty() && view.modelParams.isNotEmpty()) {
item("models-heading") {
Text("Models", style = MaterialTheme.typography.titleSmall)
Text(
"How a model is loaded belongs to the machine, not to a session: " +
"one copy of it in memory answers every session using it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
}
}
machineModels.actionError?.let { failure ->
item("models-error") {
Text(failure, color = MaterialTheme.colorScheme.error)
}
}
// Above the models: this is what is about to be one of them.
downloadCards(machineModels)
val sizes = machineModels.sizes
uniqueItems(view.models, key = { it.id }) { model ->
ModelCard(
model = model,
specs = view.modelParams,
bytes = sizes[model.id],
onDelete =
if (model.id in sizes) ({ confirmingDelete = model }) else null,
// Tapping opens the settings; a provider whose models take none has
// nothing to open, so the row is not a control.
onEdit =
if (view.modelParams.isEmpty()) null else ({ editing = model }),
onUnload =
if (model.status == "loaded" || model.status == "sleeping") {
{
act("Unloading ${model.label}") {
unloadProviderModel(
settings,
machineId,
provider,
model.id,
)
}
}
} else null,
enabled = busy == null,
)
}
if (kind == "llama_cpp") modelSearch(machineModels)
if (view.mcpServers.isNotEmpty()) {
item("mcp") {
Spacer(Modifier.height(12.dp))
Text("Tool servers", style = MaterialTheme.typography.titleSmall)
Text(
view.mcpServers.joinToString(", ") +
" — configured on the backend, in its config file.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
editing?.let { model ->
val view = (state as? LoadState.Loaded)?.value
ModelSettingsDialog(
model = model,
specs = view?.modelParams.orEmpty(),
onDismiss = { editing = null },
onSave = { params ->
editing = null
act("Saving ${model.label}") {
setModelSettings(settings, machineId, provider, model.id, params)
}
},
)
}
confirmingDelete?.let { model ->
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete ${model.label}?") },
text = {
Text(
"The file is removed from ${(state as? LoadState.Loaded)?.value?.machine ?: "this machine"}. " +
"Nothing here can get it back -- downloading it again is the whole file again. " +
"Sessions using it keep their conversations and cannot start it."
)
},
confirmButton = {
TextButton(
onClick = {
confirmingDelete = null
machineModels.remove(model.id)
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
},
)
}
if (confirmingStop) {
AlertDialog(
onDismissRequest = { confirmingStop = false },
title = { Text("Stop this server?") },
text = {
// Said plainly rather than hidden: this is the only thing that frees the memory,
// and what it costs is that every session on this machine reloads its model.
Text(
"Every model it is holding is unloaded. Sessions using it will show as " +
"exited, and the next message to one loads its model again — which is " +
"the slow part, not the sending."
)
},
confirmButton = {
TextButton(
onClick = {
confirmingStop = false
act("Stopping…") { stopProviderServer(settings, machineId, provider) }
}
) {
Text("Stop")
}
},
dismissButton = { TextButton(onClick = { confirmingStop = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun ServerCard(
server: ServerState,
maxLoaded: Int?,
enabled: Boolean,
onStop: () -> Unit,
onMaxLoaded: (Int?) -> Unit,
) {
// The saved value is what this starts at and what Save is compared against, so a field left
// half-typed is visibly not saved rather than quietly either way.
val saved = maxLoaded?.toString().orEmpty()
var typed by remember(saved) { mutableStateOf(saved) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text("Model server", style = MaterialTheme.typography.titleSmall)
Text(
if (server.running) {
"Running" + (server.port?.let { ", reached on port $it" } ?: "")
} else {
// Not a fault: nothing is loaded because nothing has asked. Saying it in
// words rather than colouring the row, since "stopped" and "we could not
// ask" would otherwise look the same.
"Not running. A session starts it when it needs a model."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = typed,
onValueChange = { typed = it.filter(Char::isDigit) },
label = { Text("Models loaded at once") },
placeholder = { Text("one -- a second model replaces the first") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
// Shown whether or not it is running, and disabled when there is nothing to stop:
// a button that comes and goes makes its own absence the message.
TextButton(enabled = enabled && server.running, onClick = onStop) { Text("Stop") }
Spacer(Modifier.weight(1f))
TextButton(
enabled = enabled && typed != saved,
onClick = { onMaxLoaded(typed.toIntOrNull()) },
) {
Text("Save")
}
}
if (typed != saved) {
Text(
"Read when this server next starts.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ModelCard(
model: ProviderModel,
specs: List<ParamSpec>,
/** How big the file is on the machine, for a provider whose models are files. */
bytes: Long?,
onEdit: (() -> Unit)?,
onUnload: (() -> Unit)?,
onDelete: (() -> Unit)?,
enabled: Boolean,
) {
Card(
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.then(if (onEdit != null && enabled) Modifier.clickable(onClick = onEdit) else Modifier)
) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
model.label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
bytes?.let {
Text(
gigabytes(it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// What the server is doing with it, in its own word. Absent means nobody could ask --
// the server is not running -- and the line is left out rather than guessed at.
model.status?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (model.settings.isNotEmpty()) {
Text(
// In the words the dialog uses, and in the order it draws them: a summary
// naming `contextSize` is a summary of a different screen than the one it
// sits under.
specs
.mapNotNull { spec ->
model.settings[spec.key]?.let { "${spec.label} $it" }
}
.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (onUnload != null || onDelete != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Both shown whenever this kind of model has them, disabled rather than
// absent: unloading frees memory and deleting frees disk, and a button that
// comes and goes makes its own absence the message.
onUnload?.let { TextButton(enabled = enabled, onClick = it) { Text("Unload") } }
Spacer(Modifier.weight(1f))
onDelete?.let { TextButton(enabled = enabled, onClick = it) { Text("Delete") } }
}
}
}
}
}
/**
* How one model is loaded.
*
* Saved on Save rather than as it is typed, unlike the session settings dialog: writing this
* unloads the model for everybody using it, which is not something to do once per keystroke.
*/
@Composable
private fun ModelSettingsDialog(
model: ProviderModel,
specs: List<ParamSpec>,
onDismiss: () -> Unit,
onSave: (Map<String, String>) -> Unit,
) {
var params by remember(model.id) { mutableStateOf(model.settings) }
AlertDialog(
onDismissRequest = onDismiss,
// Every control here is a number, so the keyboard is up for most of this dialog's life --
// and a dialog that keeps its own size under the keyboard puts Save off the bottom of the
// screen, where nothing on screen says it is there. Taking the insets ourselves is what
// lets `imePadding` shrink it instead.
properties = DialogProperties(decorFitsSystemWindows = false),
modifier = Modifier.imePadding(),
title = { Text(model.label) },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(
if (model.status == "loaded" || model.status == "sleeping") {
"This model is loaded. Saving takes it out of memory, and the sessions " +
"using it load it again with these settings on their next message."
} else {
"Read when this model is next loaded."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
ProviderParamFields(
specs = specs,
values = params,
onChange = { params = it },
// Every one of these is read at load time, and the sentence above already
// says when that is -- marking each control "on restart" would repeat it six
// times.
warnAboutRestart = false,
)
}
},
confirmButton = { TextButton(onClick = { onSave(params) }) { Text("Save") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -1,12 +1,10 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -20,14 +18,6 @@ import androidx.compose.ui.unit.dp
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three * monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
* copies of "clip, fill, pad" drift apart the first time one is adjusted. * copies of "clip, fill, pad" drift apart the first time one is adjusted.
* *
* **Nothing in here wraps; it scrolls sideways instead.** This is column-aligned far more often
* than it is prose -- a diff, a table, a test run, a command and its arguments -- and wrapping
* destroys exactly the alignment that was carrying the meaning, while turning one line into four
* and a run of them into a wall. The scroll belongs to the block rather than to each line so that
* the lines stay aligned with each other as it moves: one offset for the whole column is what makes
* a shifted diff still read as a diff. Every [Text] inside is therefore drawn with `softWrap =
* false`, which is the half of this a caller has to remember.
*
* The colour is [rawSurface], which is also what a code block inside a reply is given. * The colour is [rawSurface], which is also what a code block inside a reply is given.
*/ */
@Composable @Composable
@@ -39,10 +29,6 @@ fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.()
// rectangle drawn at the same radius as the one behind it reads as a misprint. // rectangle drawn at the same radius as the one behind it reads as a misprint.
.clip(MaterialTheme.shapes.extraSmall) .clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface) .background(rawSurface)
// Clipped and filled before this, so the tint is the viewport and does not scroll away
// from under the text; padded after it, so the inset travels with the content and the
// last column does not end flush against the edge.
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
content = content, content = content,
) )
@@ -1,284 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Dragging a row of a [androidx.compose.foundation.lazy.LazyColumn] into a different place in it.
*
* Generic rather than the session list's own, because "hold this and move it" is one gesture
* wherever it appears and the arithmetic below is the whole of it. The list itself is left alone:
* this reports a move and the caller decides what a move means -- it is the caller that holds the
* rows and the caller that tells a server about the new order.
*
* The drag is on a [ReorderHandle] rather than on the row, which is what keeps it out of the way of
* the scroll. A whole row that can be dragged sideways-ish is a row that sometimes eats a fling,
* and a list is scrolled far more often than it is rearranged.
*/
class Reorder
internal constructor(
private val listState: LazyListState,
private val scope: CoroutineScope,
private val haptics: HapticFeedback,
/** What the [EDGE] band is in pixels here; a band in raw pixels is one screen's answer. */
private val density: Density,
/**
* The caller's own lists are what move; these are [State] so that the gesture, which outlives a
* recomposition, is never holding the first composition's copy of them.
*/
private val onMove: State<(from: Int, to: Int) -> Unit>,
private val onSettled: State<() -> Unit>,
) {
/** The key of the row in hand, or null when nothing is being dragged. */
var held by mutableStateOf<Any?>(null)
private set
/** Where the list had laid the row out when it was taken hold of, in viewport pixels. */
private var grabbedAt = 0
/** How far the finger has moved since, which is what the row is drawn following. */
private var dragged by mutableFloatStateOf(0f)
/** How far the list has scrolled under it since -- see [follow]. */
private var scrolled = 0f
/** The index the row has been moved to so far, which is what the next move counts from. */
private var at = 0
/** Where it started, so that a handle merely pressed is not reported as a rearrangement. */
private var from = 0
/**
* How much of the travel below the moves so far have accounted for.
*
* The travel is what decides a crossing, rather than where the row is drawn *now*: a lazy list
* animates an item into its new place, so for a few frames after a move `offset` still reports
* roughly the old one. Deciding from that offset re-decided the same crossing on every frame
* until the animation caught up, and a drag of two rows arrived six rows down.
*/
private var settled = 0f
private fun info(key: Any): LazyListItemInfo? =
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key }
private fun itemAt(index: Int): LazyListItemInfo? =
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
/**
* How far from where the list laid it out this row should be drawn -- zero for every row but
* the one in hand.
*
* Measured against where the row is laid out *now* rather than accumulated, which is what makes
* it self-correcting: a move, or a scroll under the finger, puts the row somewhere new, and the
* same subtraction cancels that out so the row stays under the finger instead of jumping by its
* own height.
*/
fun offsetOf(key: Any): Float {
if (key != held) return 0f
val now = info(key) ?: return 0f
return grabbedAt + dragged - now.offset
}
internal fun grab(key: Any) {
val from = info(key) ?: return
held = key
grabbedAt = from.offset
at = from.index
this.from = from.index
dragged = 0f
scrolled = 0f
settled = 0f
// The platform's "you have picked this up", the same feedback a long press gives, because
// the gesture it confirms is the same kind of commitment.
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
internal fun drag(by: Float) {
if (held == null) return
dragged += by
cross()
}
/**
* Trades places with as many neighbours as the travel so far has earned.
*
* Half a neighbour's height each way, so the row changes place when it covers most of the one
* it is passing -- and a full height of hysteresis before it can come back, since the move has
* already paid that half in the other direction. A loop rather than one step: a fast drag, or a
* list scrolling under a parked finger, crosses several rows between two events.
*/
private fun cross() {
while (true) {
val slack = dragged + scrolled - settled
val next = itemAt(if (slack > 0) at + 1 else at - 1) ?: return
if (abs(slack) < next.size / 2f) return
// Where the list is looking, taken before the move and put back after it. A lazy list
// keeps its place by the *key* of the item at the top, so moving that item takes the
// viewport with it -- drag the top row down two places and the list scrolls two rows
// to follow it, which reads as the row never having moved. The correction is by index,
// which is the thing that did not change.
val anchor = listState.firstVisibleItemIndex
val within = listState.firstVisibleItemScrollOffset
onMove.value(at, next.index)
// Requested rather than scrolled to: this has to take effect in the *same* measurement
// as the move, and a scroll launched beside it lands before the list has taken the new
// order and is then undone by it.
listState.requestScrollToItem(anchor, within)
settled += if (slack > 0) next.size.toFloat() else -next.size.toFloat()
at = next.index
// Loud on purpose: the row is under a finger that is covering it, so the tick is how
// the reader knows a place was taken rather than that they are still between two.
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
internal fun release() {
// Only where the row actually went somewhere: a handle pressed and let go has rearranged
// nothing, and reporting one would have the server rewrite the order it already has.
val moved = held != null && at != from
held = null
dragged = 0f
scrolled = 0f
settled = 0f
if (moved) onSettled.value()
}
/**
* Scrolls the list while the row in hand is held against one end of it, so a row can be moved
* further than one screenful. A frame loop rather than a response to the drag, because a finger
* parked at the bottom edge sends no more events and is exactly the case this exists for.
*/
internal fun follow() {
val key = held ?: return
scope.launch {
while (held == key) {
withFrameNanos {}
val moving = info(key) ?: continue
val viewport = listState.layoutInfo.viewportEndOffset
val edge = with(density) { EDGE.toPx() }
val top = grabbedAt + dragged
val bottom = top + moving.size
val step =
when {
top < edge -> -(edge - top).coerceAtMost(edge)
bottom > viewport - edge -> (bottom - (viewport - edge)).coerceAtMost(edge)
else -> 0f
}
if (step == 0f) continue
// Counted as travel of its own: the finger has not moved, but the rows have moved
// under it, which is the same thing to everything above. Nothing is added to the
// drag, because where the row is *drawn* is measured against the list's own
// offsets and those have already moved.
scrolled += listState.scrollBy(step * SPEED)
cross()
}
}
}
private companion object {
/** How close to an end of the list a held row has to be before the list follows it. */
val EDGE = 36.dp
/** A fraction of the overshoot per frame, so the scroll eases in rather than lurching. */
const val SPEED = 0.12f
}
}
@Composable
fun rememberReorder(
listState: LazyListState,
/** Two indices into the lazy list, which is the caller's own order to rearrange. */
onMove: (from: Int, to: Int) -> Unit,
/** The drag is over: the order on screen is the one to keep. */
onSettled: () -> Unit,
): Reorder {
val move = rememberUpdatedState(onMove)
val settled = rememberUpdatedState(onSettled)
val haptics = LocalHapticFeedback.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
return remember(listState) { Reorder(listState, scope, haptics, density, move, settled) }
}
/**
* The handle a row is dragged by: the burger, at about the size of a heading.
*
* Bigger than an icon beside a line of text -- this is what a row is taken hold of by, and at
* [GLYPH_SIZE] it read as decoration on the end of the row. Not as big as the row either: a mark
* scaled to the card's whole inner height came out heavier than anything else on screen, since
* these rules thicken with the glyph.
*
* The touch square around it is [GLYPH_BUTTON_SIZE], the same as every other icon control here, so
* the mark and the area that answers to a finger are two different sizes -- which is why the caller
* subtracts [HANDLE_MARGIN] from the gap it wants: what has to line up with the text on the other
* side is the mark, not the box around it.
*
* [key] is the row's own key in the list, which is how a gesture that started here finds the row it
* belongs to -- an index would be stale the moment the first move landed.
*/
@Composable
fun ReorderHandle(state: Reorder, key: Any, modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.size(GLYPH_BUTTON_SIZE)
// Nothing here draws a word, and a handle is the kind of control somebody using a
// screen reader has no other way to find.
.semantics { contentDescription = "Drag to reorder" }
.pointerInput(key) {
detectDragGestures(
onDragStart = {
state.grab(key)
state.follow()
},
onDrag = { _, amount -> state.drag(amount.y) },
onDragEnd = { state.release() },
onDragCancel = { state.release() },
)
},
) {
Glyph(DRAG_GLYPH, colour = MaterialTheme.colorScheme.onSurfaceVariant, size = HANDLE_MARK)
}
}
/** How big the mark itself is: a heading's size, which is what the font is asked for in `sp`. */
private val HANDLE_MARK = 24.sp
/**
* How much of the touch square lies outside the mark on each side.
*
* A caller that wants the *mark* a given distance from something takes this off that distance --
* see the rule about aligning the mark rather than the box it is centred in.
*/
val HANDLE_MARGIN = (GLYPH_BUTTON_SIZE - HANDLE_MARK.value.dp) / 2
@@ -1,89 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* The line under a finished reply: what it cost to produce, and when it was sent.
*
* Small and set back, in the tone the session's own subtitle takes: it is about the message rather
* than part of it, and at the reply's own size it would read as the last thing the model said.
*
* Right-aligned because it closes the message rather than opening one -- a reader scanning down the
* left edge is reading what was said, and this is where that ends.
*/
@Composable
fun ReplyFooter(
ts: Double,
tokensPerSecond: Double?,
prefillMs: Long?,
modifier: Modifier = Modifier,
) {
val text = replyFooterText(ts, tokensPerSecond, prefillMs, ZoneId.systemDefault()) ?: return
Text(
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = modifier.fillMaxWidth(),
)
}
/**
* What the footer says, or null when there is nothing to say: "read 9.5s · 50.3 tok/s · 3:00 PM".
*
* Split out so the wording is testable without a screen, and [zone] is a parameter for the same
* reason [limitSummary] takes one: a test has to say the same thing wherever it runs.
*
* **The time is last, and so sits against the right edge whatever else is on the line.** The
* measurements in front of it are the provider's, so a session on another provider has fewer of
* them or none -- and a reader who has learned where the clock is should not have to find it again
* because the model changed. The costs grow leftwards into the space instead.
*
* Those measurements are drawn only where the provider made them. Most do not -- a coding CLI
* reports what a turn cost and never how long the model spent on it -- and the time this app
* watched a reply arrive over is a different quantity: it counts the network, the pauses between
* tokens and whatever else the machine was doing. So the line is the clock alone rather than a
* plausible figure beside it.
*/
fun replyFooterText(
ts: Double,
tokensPerSecond: Double?,
prefillMs: Long?,
zone: ZoneId,
): String? {
val at =
if (ts <= 0.0) null
else
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone)
.format(Instant.ofEpochMilli((ts * 1000).toLong()))
} catch (_: Exception) {
null
}
// A tenth up to three digits, where the difference between 18 and 18.4 tok/s is something a
// reader comparing two models can use; past that the tenth is noise on a figure that moves by
// more than that between turns.
val rate =
tokensPerSecond
?.takeIf { it > 0.0 }
?.let {
if (it >= 100) String.format(Locale.getDefault(), "%.0f tok/s", it)
else String.format(Locale.getDefault(), "%.1f tok/s", it)
}
// Named "read" rather than given a unit alone, because a second figure in seconds beside a
// rate is unreadable otherwise -- and it is the same word the status row uses while it is
// happening, so the wait and the figure for it are one vocabulary.
val read = prefillMs?.takeIf { it > 0 }?.let { "read ${formatMillis(it)}" }
return listOfNotNull(read, rate, at).joinToString(" · ").ifEmpty { null }
}
@@ -31,13 +31,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
/** /**
* A session wanting attention, said over the app as well as in Android's drawer. * A session wanting attention, said over the app rather than through Android's drawer.
* *
* Two places carry the same fact and they are doing different jobs: a row in the shade waits * Two places can carry the same fact and only one is right at a time. A row in the shade is for
* however long it has to, which makes it the record, and a banner is read now or not at all, which * somebody looking at something else: it makes a sound, it waits however long it has to, and acting
* makes it the interruption. So somebody with the app open gets both -- this, and a silent row * on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
* behind it that is still there when they go looking and goes by itself when they open the session. * while these are on screen the stream is delivered here instead, which is arranged by the
* Whether the app is open at all is this collection and nothing else. * collection below and nothing else.
* *
* A banner can go three ways, each somebody deciding something different: tapped, which opens the * 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. * session; pushed off either side; or left alone, in which case it goes when the bar runs out.
@@ -5,32 +5,24 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
@@ -38,20 +30,12 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -156,23 +140,12 @@ fun SessionImageViewer(
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref) val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
val view = LocalView.current
var hiddenBars by remember(ref) { mutableStateOf(ViewerBars()) }
var barInsets by remember(ref) { mutableStateOf(ViewerBarInsets()) }
Dialog( Dialog(
onDismissRequest = onClose, onDismissRequest = onClose,
properties = properties = DialogProperties(usePlatformDefaultWidth = false),
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) { ) {
ViewerSystemBars(hiddenBars)
Box( Box(
Modifier.fillMaxSize() Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClose,
),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
when (val image = bitmap) { when (val image = bitmap) {
@@ -192,46 +165,7 @@ fun SessionImageViewer(
// beside it are. // beside it are.
CircularProgressIndicator(color = Color.White) CircularProgressIndicator(color = Color.White)
} }
else -> { else -> ZoomableImage(image)
var viewport by remember { mutableStateOf(IntSize.Zero) }
var nativeSizeRequest by remember { mutableIntStateOf(0) }
ZoomableImage(
image,
nativeSizeRequest = nativeSizeRequest,
onViewportChanged = {
viewport = it
ViewCompat.getRootWindowInsets(view)?.let { insets ->
barInsets =
ViewerBarInsets(
status =
insets
.getInsetsIgnoringVisibility(
WindowInsetsCompat.Type.statusBars()
)
.top,
navigation =
insets
.getInsetsIgnoringVisibility(
WindowInsetsCompat.Type.navigationBars()
)
.bottom,
)
}
},
onBarsChanged = { hiddenBars = it },
barInsets = barInsets,
viewport = viewport,
)
Button(
onClick = { nativeSizeRequest++ },
modifier =
Modifier.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(16.dp),
) {
Text("100%")
}
}
} }
} }
} }
@@ -291,72 +225,34 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality
* The image on its own, as large as it fits, with pinch to zoom. * The image on its own, as large as it fits, with pinch to zoom.
* *
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back * 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, with the whole * gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
* image visible without enlarging a smaller one; the 100% control changes to one bitmap pixel per * visible.
* screen pixel and recenters it.
*/ */
@Composable @Composable
private fun ZoomableImage( private fun ZoomableImage(image: ImageBitmap) {
image: ImageBitmap,
nativeSizeRequest: Int,
onViewportChanged: (IntSize) -> Unit,
onBarsChanged: (ViewerBars) -> Unit,
barInsets: ViewerBarInsets,
viewport: IntSize,
) {
var scale by remember { mutableFloatStateOf(1f) } var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) } var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) }
val nativeScale = nativeScale(image.width, image.height, viewport.width, viewport.height)
LaunchedEffect(nativeSizeRequest, nativeScale) {
if (nativeSizeRequest > 0) {
scale = nativeScale
offsetX = 0f
offsetY = 0f
}
}
val bars =
viewerBars(
image.width,
image.height,
viewport.width,
viewport.height,
scale,
Offset(offsetX, offsetY),
barInsets,
)
SideEffect { onBarsChanged(bars) }
Image( Image(
bitmap = image, bitmap = image,
contentDescription = "Attached image", contentDescription = "Attached image",
contentScale = ContentScale.Inside, contentScale = ContentScale.Fit,
// Zoomed in, the reader is looking at pixels on purpose. // Zoomed in, the reader is looking at pixels on purpose.
filterQuality = FilterQuality.None, filterQuality = FilterQuality.None,
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.onSizeChanged(onViewportChanged) .pointerInput(Unit) {
.pointerInput(nativeScale) { detectTransformGestures { _, pan, zoom, _ ->
detectTransformGestures { centroid, pan, zoom, _ -> // Floor of 1 so the image cannot be pinched smaller than fitted, which is
val oldScale = scale // already the whole of it; a ceiling so it cannot be lost off-screen.
val maximumScale = maxOf(8f, nativeScale) scale = (scale * zoom).coerceIn(1f, 8f)
val newScale = (oldScale * zoom).coerceIn(1f, maximumScale) if (scale > 1f) {
if (newScale > 1f) { offsetX += pan.x
val offset = offsetY += pan.y
zoomOffset(
Offset(offsetX, offsetY),
centroid,
pan,
oldScale,
newScale,
Offset(size.width / 2f, size.height / 2f),
)
offsetX = offset.x
offsetY = offset.y
} else { } else {
offsetX = 0f offsetX = 0f
offsetY = 0f offsetY = 0f
} }
scale = newScale
} }
} }
.graphicsLayer { .graphicsLayer {
@@ -367,104 +263,3 @@ private fun ZoomableImage(
}, },
) )
} }
/** Lets the picture use the whole display, hiding only the system bars it actually reaches. */
@Composable
private fun ViewerSystemBars(hidden: ViewerBars) {
val view = LocalView.current
val window = (view.parent as? DialogWindowProvider)?.window
val controller = window?.let { WindowCompat.getInsetsController(it, view) }
SideEffect {
controller?.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
if (hidden.status) {
controller?.hide(WindowInsetsCompat.Type.statusBars())
} else {
controller?.show(WindowInsetsCompat.Type.statusBars())
}
if (hidden.navigation) {
controller?.hide(WindowInsetsCompat.Type.navigationBars())
} else {
controller?.show(WindowInsetsCompat.Type.navigationBars())
}
}
DisposableEffect(view) {
onDispose {
controller?.show(
WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()
)
}
}
}
internal data class ViewerBars(val status: Boolean = false, val navigation: Boolean = false)
internal data class ViewerBarInsets(val status: Int = 0, val navigation: Int = 0)
/** Which full-screen system-bar regions the fitted, zoomed and panned image intersects. */
internal fun viewerBars(
imageWidth: Int,
imageHeight: Int,
viewportWidth: Int,
viewportHeight: Int,
scale: Float,
offset: Offset,
insets: ViewerBarInsets,
): ViewerBars {
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) {
return ViewerBars()
}
val fittedScale = insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
val width = imageWidth * fittedScale * scale
val height = imageHeight * fittedScale * scale
val left = viewportWidth / 2f + offset.x - width / 2f
val right = left + width
val top = viewportHeight / 2f + offset.y - height / 2f
val bottom = top + height
val crossesScreen = right > 0f && left < viewportWidth
return ViewerBars(
status = crossesScreen && insets.status > 0 && bottom > 0f && top < insets.status,
navigation =
crossesScreen &&
insets.navigation > 0 &&
bottom > viewportHeight - insets.navigation &&
top < viewportHeight,
)
}
/** Scale relative to [ContentScale.Inside] at which bitmap and screen pixels are one-to-one. */
internal fun nativeScale(
imageWidth: Int,
imageHeight: Int,
viewportWidth: Int,
viewportHeight: Int,
): Float {
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) return 1f
return 1f / insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
}
/** The downscale-only factor used by [ContentScale.Inside]. */
private fun insideScale(
imageWidth: Int,
imageHeight: Int,
viewportWidth: Int,
viewportHeight: Int,
): Float =
minOf(
1f,
viewportWidth.toFloat() / imageWidth,
viewportHeight.toFloat() / imageHeight,
)
/** Keeps the image point beneath [centroid] beneath the fingers as its scale changes. */
internal fun zoomOffset(
offset: Offset,
centroid: Offset,
pan: Offset,
oldScale: Float,
newScale: Float,
viewportCenter: Offset,
): Offset {
val scaleChange = newScale / oldScale
return offset * scaleChange + (centroid - viewportCenter) * (1f - scaleChange) + pan
}
@@ -1,28 +1,26 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -35,30 +33,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* The sessions tab: every session, in the order the reader has put them in. * The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
*
* Nothing here sorts. The order is the server's `sessions` list and the reader's own -- see
* [reorderSessions] -- which is the one arrangement a row cannot be moved out of by something the
* session does. It replaced sorting by activity, and then sorting by when each agent was turned on:
* both meant a list that rearranged itself under whoever was reading it, and the status word and
* its colour already say which session wants something without the row having to move to say it.
*
* Holding a row puts the screen in selection mode, the same gesture and the same bottom bar as the
* import tab, so the two lists are learned once. Rearranging is deliberately *not* part of a
* selection -- the handle moves the row it is on, whether or not that row is picked out -- because
* "which rows am I acting on" and "where does this one go" are two questions.
* *
* No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one. * No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
* What stays here is the button that adds a session, because that acts on this list and nothing * What stays here is the button that adds a session, because that acts on this list and nothing
@@ -69,24 +53,39 @@ fun SessionListScreen(
settings: ServerSettings, settings: ServerSettings,
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
/** A session this list has just deleted, for whoever is showing it elsewhere. */
onDeleted: (String) -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) } var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Which rows the reader has picked out. Empty means selection mode is off, as on the import // Which session cards are expanded to show their subagents, and what each expansion fetched.
// tab: a selection mode with nothing in it has no controls and no way out but Back. // Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
var selected by remember { mutableStateOf<Set<String>>(emptySet()) } // from
// whatever the server last said, and this belongs to the reader's own choice, which survives a
// refresh.
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
var subagentLoads by remember {
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
}
// The sessions a delete has been confirmed for, or none. A list rather than one session, fun loadSubagents(sessionId: String) {
// because a selection is what the bar below acts on. subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
var confirmingDelete by remember { mutableStateOf<List<SessionSummary>>(emptyList()) } scope.launch {
subagentLoads =
// Whether an answer is outstanding, which is a different question from whether there is subagentLoads +
// anything to draw: see [refresh]. (sessionId to
var reloading by remember { mutableStateOf(false) } try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
)
} catch (e: ApiException) {
LoadState.failed(e)
})
}
}
// Failures that belong to one session rather than to the list, keyed by its id and shown on its // 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, // own card. The two scopes are decided by whether the server answered: it answered and refused,
@@ -95,10 +94,6 @@ fun SessionListScreen(
// Cleared on the next successful load below -- an entry outlives its session otherwise. // Cleared on the next successful load below -- an entry outlives its session otherwise.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) } var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Why the order on screen is not the order that was saved, when saving one failed. The list is
// what failed, so it is reported over the list rather than on any row.
var orderError by remember { mutableStateOf<String?>(null) }
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because // 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. // the rows are rebuilt from whatever the server last said and this belongs to the request.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) } var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
@@ -109,142 +104,39 @@ fun SessionListScreen(
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
fun refresh() { fun refresh() {
// The rows stay while the answer is on its way, with the bar below saying one is: this listState = LoadState.Loading
// list is asked again every time the panel over a session is opened, and blanking it each
// time hands the reader an empty screen to report on something that was never in doubt.
// A first load has nothing to keep, and says so with the spinner instead.
if (listState !is LoadState.Loaded) listState = LoadState.Loading
reloading = true
scope.launch { scope.launch {
listState = listState =
try { try {
val loaded = val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap() deleteErrors = emptyMap()
val alive = loaded.value.map { it.id }.toSet()
// A selection is of sessions, so one deleted somewhere else leaves it. Only
// that one: the other rows the reader picked out are still there.
selected = selected.intersect(alive)
// The path out for a cached transcript whose session was deleted somewhere // 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 // 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 // answer rather than in `finally`: a list that failed to arrive says nothing
// about which sessions exist. // about which sessions exist.
withContext(Dispatchers.IO) { transcriptCache.retainOnly(alive) } withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
}
// A session gone from this answer cannot still be expanded, and an expanded one
// that is still here asks again -- its subagents may have changed since the
// last
// fetch.
val ids = loaded.value.map { it.id }.toSet()
expandedSessions = expandedSessions intersect ids
subagentLoads = subagentLoads.filterKeys { it in ids }
expandedSessions.forEach(::loadSubagents)
loaded loaded
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
reloading = false
}
}
/**
* Deletes every session in [targets], one after another.
*
* One at a time and in the order they are drawn: the server has no batch delete for sessions,
* and each one ends a process. Each row says what is happening to it from the moment the work
* is handed over, which is also when the selection goes -- a bar still naming sessions being
* deleted is a set nobody can act on.
*/
fun deleteChosen(targets: List<SessionSummary>, alsoDeleteForeign: Boolean) {
selected = emptySet()
// Marked here rather than after the request returns: a row has to say something is
// happening to it from the moment it is asked for.
deleting = deleting + targets.map { it.id }
deleteErrors = deleteErrors - targets.map { it.id }.toSet()
scope.launch {
for (session in targets) {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the session
// exactly as it was, and its transcript with it.
transcriptCache.session(TranscriptAddress(session.id)).purge()
}
// Only this row, and only what changed. Refetching the list instead put every
// other session back through loading and handed the reader an empty screen, to
// report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState = LoadState.Loaded(loaded.value.filterNot { it.id == session.id })
}
onDeleted(session.id)
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the session it
// refused about is exactly as it was.
deleteErrors = deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
} }
} }
LaunchedEffect(reloadToken) { refresh() } LaunchedEffect(reloadToken) { refresh() }
val rows = rememberLazyListState()
val reorder =
rememberReorder(
listState = rows,
onMove = { from, to ->
// Moved here and now, because the row is under a finger: waiting for the server to
// agree would drag the handle away from the card it is on. What the server thinks
// is asked for when the finger comes up, and a refusal puts the list back.
val loaded = listState
if (loaded is LoadState.Loaded) {
val moved = loaded.value.toMutableList()
moved.add(to, moved.removeAt(from))
listState = LoadState.Loaded(moved)
}
},
onSettled = {
val loaded = listState
if (loaded is LoadState.Loaded) {
val order = loaded.value.map { it.id }
scope.launch {
try {
withContext(Dispatchers.IO) { reorderSessions(settings, order) }
orderError = null
} catch (e: ApiException) {
orderError = e.message ?: "The new order couldn't be saved"
// The screen must not go on showing an arrangement nothing kept, so
// the server's own order comes back -- which is also the only way to
// see what it does think.
refresh()
}
}
}
},
)
// Back leaves selection mode rather than the tab, which is the level it is one step above.
// Nested inside MainScreen's own handler, so it wins while there is a selection.
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
// can still be scrolled to while it is up.
var barHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
// What the bar covers *now*: its measurement is kept while it is away, but nothing is
// reserved for a bar that is not up.
val covered = if (selected.isEmpty()) 0.dp else barHeight
// The spawn button floats over the list, so the list ends above it -- measured, for the
// reason the bar is. Without this the last row sat under the button, which was survivable
// while every part of a row did the same thing and is not now that corner is a handle.
var buttonHeight by remember { mutableStateOf(0.dp) }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
orderError?.let { message ->
// The server's own words, unprefixed, the way every other failure is shown.
Text(
message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
}
when (val state = listState) { when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is already a whole // The message as Api.kt wrote it, with nothing added: it is already a whole
@@ -263,32 +155,31 @@ fun SessionListScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
LazyColumn( // Awaiting-answer first (the point of the screen), then most recently active.
state = rows, val ordered =
contentPadding = state.value.sortedWith(
PaddingValues(bottom = covered + buttonHeight + BUTTON_RING * 2), compareByDescending<SessionSummary> { it.status == "awaitingInput" }
) { .thenByDescending { it.lastActivity }
uniqueItems(state.value, key = { it.id }) { session -> )
LazyColumn {
uniqueItems(ordered, key = { it.id }) { session ->
SessionCard( SessionCard(
session = session, session = session,
error = deleteErrors[session.id], error = deleteErrors[session.id],
deleting = session.id in deleting, deleting = session.id in deleting,
picked = session.id in selected, onOpen = { onOpen(session) },
// The handle is a selection-mode control, so it is absent rather onLongPress = { confirmingDelete = session },
// than disabled outside one: this is not a capability being expanded = session.id in expandedSessions,
// withheld, it is a mode the list is not in. subagents = subagentLoads[session.id],
reorder = reorder.takeIf { selected.isNotEmpty() }, onToggleSubagents = {
onClick = { if (session.id in expandedSessions) {
// In selection mode a tap is a selection, so the reader is expandedSessions = expandedSessions - session.id
// never one mis-tap away from opening a session they were only } else {
// picking rows for. expandedSessions = expandedSessions + session.id
if (selected.isEmpty()) onOpen(session) loadSubagents(session.id)
else }
selected =
if (session.id in selected) selected - session.id
else selected + session.id
}, },
onLongPress = { selected = selected + session.id }, onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
@@ -297,114 +188,70 @@ fun SessionListScreen(
} }
} }
// Over the list rather than above it: a bar that appears in the flow moves every row down
// by its own height at the moment the reader is looking at them.
if (reloading) {
LinearProgressIndicator(Modifier.align(Alignment.TopCenter).fillMaxWidth())
}
// 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 =
(listState as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
SessionSelectionBar(
count = picked.size,
modifier =
Modifier.align(Alignment.BottomCenter).onSizeChanged {
barHeight = with(density) { it.height.toDp() }
},
onDelete = { confirmingDelete = picked },
)
}
// Above the bar when there is one, by what that bar measured: the button stays rather than
// coming and going, since an absent control cannot say whether there was nothing to do.
FloatingActionButton( FloatingActionButton(
onClick = onSpawn, onClick = onSpawn,
modifier = modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
Modifier.align(Alignment.BottomEnd)
.padding(end = BUTTON_RING, bottom = BUTTON_RING + covered)
.onSizeChanged { buttonHeight = with(density) { it.height.toDp() } },
) { ) {
Text("+", style = MaterialTheme.typography.headlineMedium) Text("+", style = MaterialTheme.typography.headlineMedium)
} }
} }
val targets = confirmingDelete confirmingDelete?.let { session ->
if (targets.isNotEmpty()) { // Reset per session, so a toggle turned on for one conversation is not still on for the
// Reset per selection, so a toggle turned on for one set of conversations is not still // next. Off to begin with: see [deleteSession].
// on for the next. Off to begin with: see [deleteSession]. var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
var alsoDeleteForeign by remember(targets) { mutableStateOf(false) }
// Whichever of these keep a transcript of their own decide what the sentences below say,
// and whether the switch is offered at all. Old servers reported only the capability, when
// Claude Code was its sole owner.
val owned = targets.filter { it.keepsOwnTranscript }
val transcriptOwner = owned.firstOrNull()?.ownTranscriptName ?: "Claude Code"
AlertDialog( AlertDialog(
onDismissRequest = { confirmingDelete = emptyList() }, onDismissRequest = { confirmingDelete = null },
title = { title = { Text("Delete \"${session.title}\"?") },
Text(
if (targets.size == 1) "Delete \"${targets.first().title}\"?"
else "Delete ${targets.size} sessions?"
)
},
text = { text = {
// Two different acts behind one button, so it says which one this is. What // 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 // separates them is whether the *driver* keeps its own record of the conversation
// conversation // -- the Claude Code CLI does, whether this app spawned the session or imported it;
// -- the coding CLIs do, whether this app spawned the session or imported it;
// echo and llama.cpp do not. // echo and llama.cpp do not.
// //
// This used to branch on `imported`, above a comment asserting that "a session // This used to branch on `imported`, above a comment asserting that "a session
// started here has no copy anywhere". That was false for every coding-CLI session // started here has no copy anywhere". That was false for every claude-cli session
// this app spawned, and getting it wrong in that direction is the expensive one: // this 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 that // "this can't be undone", said of something that can, spends the credibility the
// sentence needs. // sentence needs.
// //
// Neither branch promises a restore. The recoverable one says what is known, // Neither branch promises a restore. The recoverable one says what is known -- the
// that the driver keeps its own record, rather than that the file is still there, // driver keeps its own record -- rather than that the file is still there, and it
// and it names what goes either way, because this app's transcript holds images, // names what goes either way, because this app's transcript holds images, peer
// peer messages and commands the CLI's own record never had. // messages and commands the CLI's own record never had.
//
// A selection takes the sentence that covers all of it: "some of these" is what
// makes the mixed case true without either half of it being read as a promise
// about every row.
Column { Column {
Text( Text(
when { when {
owned.isEmpty() -> !session.keepsOwnTranscript ->
"Kills the process and deletes the conversation. Nothing else " + "Kills the process and deletes the conversation. Nothing else " +
"keeps a copy, so this can't be undone." "keeps a copy, so this can't be undone."
// The sentence below is the one the toggle makes false, which is // The sentence below is the one the toggle makes false, which is why it
// why it is written twice rather than appended to: "should still be // is written twice rather than appended to: leaving "should still be
// there to import again", left on screen beside a switch that removes // there to import again" on screen beside a switch that removes it is
// it, is the reassurance being read as it stops being true. // the reassurance being read at the moment it stops being true.
alsoDeleteForeign -> alsoDeleteForeign ->
"Kills the process and deletes both copies of the conversation: " + "Kills the process and deletes both copies of the conversation: " +
"this app's, and $transcriptOwner's own transcript on the " + "this app's, and Claude Code's own transcript on the " +
"machine. Nothing keeps another, so this can't be undone." "machine. Nothing keeps another, so this can't be undone."
else -> else ->
"Stops the process and deletes this app's copy of the " + "Stops the process and deletes this app's copy of the " +
"conversation, including any images, peer messages and " + "conversation, including any images, peer messages and " +
"commands recorded only here. $transcriptOwner keeps its own " + "commands recorded only here. Claude Code keeps its own " +
"transcript on the machine" + "transcript on the machine, so the conversation itself " +
(if (owned.size < targets.size) " for some of these" else "") + "should still be there to import again."
", so the conversation itself should still be there to " +
"import again."
} }
) )
// Only where there is a second copy to decide about. Absent rather than // 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 // 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 // llama.cpp there is no other transcript, and a switch offering to delete one
// would be asking about something that does not exist. // would be asking about something that does not exist.
if (owned.isNotEmpty()) { if (session.keepsOwnTranscript) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Its own row rather than beside the paragraph: a switch is taller // Its own row rather than beside the paragraph: a switch is taller than a
// than a line of text and re-centres whatever shares a row with it. // line of text and re-centres whatever shares a row with it.
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"Delete $transcriptOwner's transcript too", "Delete Claude Code's transcript too",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -420,8 +267,38 @@ fun SessionListScreen(
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
confirmingDelete = emptyList() confirmingDelete = null
deleteChosen(targets, alsoDeleteForeign) // Marked here rather than after the request returns: the row has to say
// something is happening to it from the moment it is asked for.
deleting = deleting + session.id
deleteErrors = deleteErrors - session.id
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it.
transcriptCache.session(TranscriptAddress(session.id)).purge()
}
// Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the
// reader an empty screen, to report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState =
LoadState.Loaded(
loaded.value.filterNot { it.id == session.id }
)
}
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the
// session it refused about is exactly as it was.
deleteErrors =
deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
} }
) { ) {
// Coloured by consequence: this takes something away, and does so wherever it // Coloured by consequence: this takes something away, and does so wherever it
@@ -430,54 +307,12 @@ fun SessionListScreen(
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { confirmingDelete = emptyList() }) { Text("Cancel") } TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
}, },
) )
} }
} }
/**
* The ring of space inside a session's card, which is also what its handle leaves around itself.
*/
private val CARD_PADDING = 16.dp
/** The gap the spawn button keeps from the edges it floats over, and from the list above it. */
private val BUTTON_RING = 24.dp
/**
* What can be done to the sessions that are selected.
*
* Delete only, for now, which is the one thing this screen has ever done to a session from the list
* rather than from inside it. The same bar as the import tab's, down to the wording of the count.
*/
@Composable
private fun SessionSelectionBar(
count: Int,
modifier: Modifier = Modifier,
onDelete: () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 3.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
"$count selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDelete) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SessionCard( private fun SessionCard(
@@ -492,53 +327,28 @@ private fun SessionCard(
* about a request that has not been answered yet. * about a request that has not been answered yet.
*/ */
deleting: Boolean, deleting: Boolean,
/** Whether this row is one of the selection the bottom bar acts on. */ onOpen: () -> Unit,
picked: Boolean,
/** The drag this row can be moved by, or null where the list is not in selection mode. */
reorder: Reorder?,
onClick: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit,
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
expanded: Boolean,
/** What the expander's own fetch answered, or null before it has been asked. */
subagents: LoadState<List<SubagentSummary>>?,
onToggleSubagents: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
) { ) {
val held = reorder?.held == session.id
BusyItem(label = if (deleting) "deleting" else null) { BusyItem(label = if (deleting) "deleting" else null) {
Card( Card(
colors = // Off while the delete is in flight: a card that still opens a session it is deleting
if (picked) // is a race the reader can start by tapping. On the card rather than in [BusyItem],
CardDefaults.cardColors( // which leaves gestures alone so the list still scrolls.
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
else CardDefaults.cardColors(),
// Lifted while it is in hand, which is the one cue that says this row is being carried
// rather than sitting where it belongs.
elevation = CardDefaults.cardElevation(defaultElevation = if (held) 8.dp else 0.dp),
modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
// Drawn where the finger has taken it, above the rows it is passing over. Both
// in the layer rather than in the layout, so nothing around it moves and the
// list does not remeasure per frame of a drag.
.zIndex(if (held) 1f else 0f)
.graphicsLayer { translationY = reorder?.offsetOf(session.id) ?: 0f },
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
// Everything but the handle, which is what makes the two gestures separate
// rather than competing: a press that lands on the handle never reaches this,
// so holding it cannot select the row it is about to move. The card had the
// click while the handle was the only thing inside it that did not want one,
// and a hold on the handle then both selected the row and ate the drag.
//
// 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. Here rather than in
// [BusyItem], which leaves gestures alone so the list still scrolls.
Modifier.weight(1f)
.combinedClickable( .combinedClickable(
enabled = !deleting, enabled = !deleting,
onClick = onClick, onClick = onOpen,
onLongClick = onLongPress, onLongClick = onLongPress,
) )
.padding(CARD_PADDING)
) { ) {
Column(Modifier.padding(16.dp)) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -549,23 +359,15 @@ private fun SessionCard(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
StatusText(session.status) StatusText(session.status)
if (session.backgroundTasks > 0) {
Text(
backgroundTaskLabel(session.backgroundTasks),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
Text( Text(
// Machine, then what runs on it, then what it is set to: the same order // Machine, then what runs on it, then what it is set to: the same order and
// and separator as the session screen's header and the usage dialog, so // separator as the session screen's header and the usage dialog, so one
// one pair of facts is not written three ways. // pair of facts is not written three ways.
listOfNotNull( listOfNotNull(
session.machineName, session.setupName,
session.provider, session.provider,
session.model?.let { modelLabel(it) }, session.model?.let { modelLabel(it) },
) )
@@ -589,32 +391,119 @@ private fun SessionCard(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
// Nothing at all for a card with no subagents: a disabled expander here would be
// noise on every ordinary session's card. Its own row at the bottom rather than
// beside the title or the machine line, so opening it never displaces text that was
// already on screen -- see UI_RULES on a control not displacing the text beside it.
if (session.subagents > 0) {
Spacer(Modifier.height(8.dp))
// The platform's minimum touch height, not the chevron's own ten or so dp:
// at the chevron's height a tap meant for it landed on the first subcard
// beneath and opened a subagent instead.
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(enabled = !deleting, onClick = onToggleSubagents)
.semantics {
contentDescription =
if (expanded) "Collapse subagents" else "Expand subagents"
},
) {
Chevron(if (expanded) Pointing.Up else Pointing.Down)
} }
// Inside the card, so what it moves is the thing it is drawn on. Nothing is held if (expanded) {
// open for it outside selection mode: the row is then the row it always was. Spacer(Modifier.height(4.dp))
if (reorder != null) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ReorderHandle( when (subagents) {
reorder, null,
session.id, is LoadState.Loading ->
// Dimmed with the rest of the row while something is happening to it, since CircularProgressIndicator(
// a row on its way out is not one to rearrange -- see [BusyItem], whose modifier = Modifier.width(20.dp).height(20.dp),
// appearance this matches rather than repeating its dimming rule. strokeWidth = 2.dp,
// The mark lines up with the text on the other side of the card, )
// which means taking the square it is centred in off the gap: see is LoadState.Error ->
// [HANDLE_MARGIN]. // Said here rather than left silent: a fetch that failed and an
Modifier.alpha(if (deleting) 0.4f else 1f) // expander that simply found nothing must not look the same --
.padding(end = CARD_PADDING - HANDLE_MARGIN), // see UI_RULES on designing the unknown state first.
Text(
subagents.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded ->
subagents.value.forEach { subagent ->
SubagentCard(
subagent,
onClick = { onOpenSubagent(subagent) },
) )
} }
} }
} }
} }
} }
}
}
}
}
/**
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
* filled one, so the nesting reads as one step rather than as another session.
*/
@Composable
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped.
*/
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@Composable @Composable
fun StatusText(status: String) { fun StatusText(status: String) {
val label = sessionStatusWord(status) val (label, color) =
val color = sessionStatusColour(status) when (status) {
"awaitingInput" -> "your turn" to awaitingColor
"running" -> "running" to runningColor
"compacting" -> "compacting" to commandColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree: the
// session is not idle and has not exited, nobody has been able to find out which. A
// muted colour alone would read as one of the quiet states.
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (sessionWorking(status)) { if (sessionWorking(status)) {
// The same colour as the word beside it: the two are one signal, and a spinner in the // The same colour as the word beside it: the two are one signal, and a spinner in the
File diff suppressed because it is too large. Load diff
@@ -44,10 +44,8 @@ import kotlinx.coroutines.withContext
* screen of its own until 2026-08-30, which put a page transition and a back stack around two * screen of its own until 2026-08-30, which put a page transition and a back stack around two
* controls and hid the thing they act on. * controls and hid the thing they act on.
* *
* The model and the permission mode are on the session's own bar as well, because those are changed * The model and the permission mode are deliberately still on the session's own bar, because those
* *while* reading a turn -- "not this model, try that one". They are here too because that bar is * are changed *while* reading a turn -- "not this model, try that one".
* one row shared with three actions: a long model name leaves the other picker a few pixels wide,
* and this is where somebody goes looking for a setting anyway.
* *
* Captions are for what a control costs rather than for what it is. A paragraph under every control * 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 * made the dialog longer than the conversation it covers -- so Notifications has none, while Move
@@ -63,36 +61,15 @@ fun SessionSettingsDialog(
title: String, title: String,
onRenamed: (String) -> Unit, onRenamed: (String) -> Unit,
/** /**
* How hard the model thinks, or null for the CLI's own default. * How hard the model thinks, as the session reports it, or null for the CLI's own default.
* *
* Owned by the screen behind this rather than held here, like [title]: this dialog is what * Taken from the row this dialog was opened over rather than fetched, because unlike the
* changes it, and a level kept only for as long as the dialog is open is the old one again the * notification switch there is nothing else that changes it: the level is this app's to set and
* next time it is opened. * the server does not resolve it into something else.
*
* Not fetched, because unlike the notification switch there is nothing else that changes it:
* the level is this app's to set and the server does not resolve it into something else.
*/ */
effort: String?, effort: String?,
onEffortChanged: (String?) -> Unit,
/** Whether a level does anything here; the row is left out entirely where it does not. */ /** Whether a level does anything here; the row is left out entirely where it does not. */
takesEffort: Boolean, takesEffort: Boolean,
/**
* The settings that are one of a list -- the model and the permission mode.
*
* Owned by the screen behind this, like [title] and [effort]: it is what asked the machine what
* the provider offers. Whichever of them this one has no answer for is not in the list, and
* draws no row.
*/
choices: List<SessionChoice>,
/**
* The settings this session's provider takes, and what they are set to.
*
* Declared by the server rather than listed here -- see [ProviderParamFields]. Empty for a
* provider with none, which draws no section at all.
*/
paramSpecs: List<ParamSpec>,
params: Map<String, String>,
onParamsChanged: (Map<String, String>) -> Unit,
/** /**
* What this phone is holding of the conversation, or null while that is being measured -- see * 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. * the Reload row below, which is what would discard it.
@@ -105,9 +82,17 @@ fun SessionSettingsDialog(
* measures is that screen's own state. * measures is that screen's own state.
*/ */
onCopyRenderReport: () -> Unit, onCopyRenderReport: () -> Unit,
/**
* Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on
* every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than
* always-present-but-disabled: this has no meaning at all outside the bench build, and a
* control with nothing behind it on every other build is not a state worth drawing.
*/
onRunBenchmark: (() -> Unit)? = null,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) } var name by remember(sessionId) { mutableStateOf(title) }
var level by remember(sessionId) { mutableStateOf(effort) }
var effortError by remember { mutableStateOf<String?>(null) } var effortError by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) } var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
@@ -187,14 +172,14 @@ fun SessionSettingsDialog(
* control that stays where it was put after a refusal is stating something untrue. * control that stays where it was put after a refusal is stating something untrue.
*/ */
fun setEffort(chosen: String?) { fun setEffort(chosen: String?) {
val was = effort val was = level
onEffortChanged(chosen) level = chosen
effortError = null effortError = null
scope.launch { scope.launch {
try { try {
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) } withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
} catch (e: ApiException) { } catch (e: ApiException) {
onEffortChanged(was) level = was
effortError = e.message effortError = e.message
} }
} }
@@ -432,20 +417,6 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
choices.forEach { choice ->
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(choice.label, modifier = Modifier.weight(1f))
PickerButton(
current = choice.current,
options = choice.options,
onPick = choice.onPick,
)
}
}
// Left out rather than disabled, the one place this dialog does that: a disabled // Left out rather than disabled, the one place this dialog does that: a disabled
// control teaches what the thing can do, and a llama session cannot do this at all // control teaches what the thing can do, and a llama session cannot do this at all
// -- the row would be teaching something false about it. // -- the row would be teaching something false about it.
@@ -457,7 +428,7 @@ fun SessionSettingsDialog(
) { ) {
Text("Thinking", modifier = Modifier.weight(1f)) Text("Thinking", modifier = Modifier.weight(1f))
PickerButton( PickerButton(
current = effort ?: DEFAULT_EFFORT, current = level ?: DEFAULT_EFFORT,
// The level the CLI picks for itself is in the list as well as in the // The level the CLI picks for itself is in the list as well as in the
// button, so leaving a level is not a one-way trip -- the same // button, so leaving a level is not a one-way trip -- the same
// correction the model picker carries. // correction the model picker carries.
@@ -484,23 +455,6 @@ fun SessionSettingsDialog(
) )
} }
} }
if (paramSpecs.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
Text(
"Model settings",
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
// Edited here and saved by the screen behind this, which is what makes
// typing in a text field affordable: the save is debounced, and a dialog
// dismissed mid-edit would take an unsaved value with it.
ProviderParamFields(
specs = paramSpecs,
values = params,
onChange = onParamsChanged,
warnAboutRestart = true,
)
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -561,6 +515,21 @@ fun SessionSettingsDialog(
Text("Render timings", modifier = Modifier.weight(1f)) Text("Render timings", modifier = Modifier.weight(1f))
TextButton(onClick = onCopyRenderReport) { Text("Copy") } TextButton(onClick = onCopyRenderReport) { Text("Copy") }
} }
// Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because
// ui-trace and the emulator smoke run find it by that label, the same way every
// other control here is found -- see AGENTS.md's "Driving the UI".
onRunBenchmark?.let { run ->
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("P0 benchmark", modifier = Modifier.weight(1f))
TextButton(onClick = run) { Text("Run benchmark") }
}
}
} }
}, },
// Disabled rather than absent while there is nothing to save: a button that comes and goes // Disabled rather than absent while there is nothing to save: a button that comes and goes
@@ -574,19 +543,6 @@ fun SessionSettingsDialog(
) )
} }
/**
* One session setting that is a choice from a list, as this dialog draws it.
*
* A shape rather than a pair of parameters each, because a provider may offer either of them, both
* or neither, and they are otherwise the same control.
*/
data class SessionChoice(
val label: String,
val current: String,
val options: List<String>,
val onPick: (String) -> Unit,
)
/** /**
* When the server will next look, as a local time. * When the server will next look, as a local time.
* *
@@ -1,74 +0,0 @@
package com.example.aiapp
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* What a session's status is called on screen, and what colour it is drawn in.
*
* One pair of functions rather than a branch on each screen that shows a status. There were two,
* and the second silently fell short the moment the server grew a state: `waiting` arrived and the
* session list learned the word and the colour while the session screen's status row printed the
* wire's own word in the muted grey every quiet state uses. That comment already said the words
* were "the session list's own"; this is what makes that true rather than a promise.
*
* A subagent's own three states are deliberately not here -- see `subagentStatusLabel`, which
* collapses everything it does not recognise rather than passing it through, because a subagent has
* fewer states than a session and reporting one it cannot have is worse than reporting none.
*/
fun sessionStatusWord(status: String, subagent: Boolean = false): String =
when (status) {
"idle" -> "idle"
"running" -> "running"
"compacting" -> "compacting"
// Not "running": a model coming off disk is not a model answering, and the difference is
// minutes. Said in its own word so a first message that waits is explained rather than
// looking like a session that has stopped responding. See `SessionStatus::Loading`.
//
// "model" rather than "loading" alone, because there are two waits before an answer and
// the reader is entitled to know which one they are in: this one happens once, and
// "reading prompt" below happens on every turn.
"loading" -> "loading model"
// The model has the prompt and has not started answering. Its own word for the same
// reason: a long conversation spends real time here, and reported as "running" it looked
// like a model thinking. See `SessionStatus::Reading`.
"reading" -> "reading prompt"
// Its own word, because the state it is easily mistaken for means the opposite: "idle"
// invites the reader to type something, and a waiting session is going to carry on without
// them. See `SessionStatus::Waiting`.
"waiting" -> "waiting"
"awaitingInput" -> "your turn"
// A subagent's process was always its parent's, so it had none of its own to merely stop.
"exited" -> if (subagent) "finished" else "exited"
// 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"
// A state this build has never heard of, said as itself. The nearest word we do know would
// read as a fact somebody established.
else -> status
}
fun backgroundTaskLabel(count: Int): String = "$count bg ${if (count == 1) "task" else "tasks"}"
/**
* The colour that goes with [sessionStatusWord]: the accent is spent on the states that are about
* to do something or want something, and every quiet one shares the muted colour.
*
* Stated beside whatever draws it rather than inherited -- a colour that carries meaning has to
* carry its own contrast, since the surface under it will not change to rescue it.
*/
@Composable
fun sessionStatusColour(status: String): Color =
when (status) {
"awaitingInput" -> awaitingColor
"running" -> runningColor
"compacting" -> commandColor
// The same accent as the other states that are busy on their own account, because that is
// what this is: something is happening and nothing is wanted from the reader.
"loading",
"reading" -> commandColor
"waiting" -> waitingColor
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
@@ -15,8 +15,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.Duration import java.time.Duration
@@ -85,7 +83,7 @@ class UsageFeed(
return when (val state = snapshots) { return when (val state = snapshots) {
is LoadState.Loading -> SessionUsage.Waiting is LoadState.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message) is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model) is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
} }
} }
} }
@@ -127,8 +125,8 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
* *
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a * 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. * blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows this session's provider returned rather than the three Claude * Taken over however many windows came back rather than the three Claude sends today -- the backend
* sends today -- the backend passes windows it does not recognise straight through. * passes windows it does not recognise straight through.
* *
* Every state that is not a measurement takes the ordinary control colour instead. That is the * 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 * point where colour stops being able to help: blue is the low end of a scale here, so colouring an
@@ -144,7 +142,7 @@ fun usageGlyphColour(usage: SessionUsage): Color =
} }
/** /**
* The shortest usage window for the pool this session uses, under the session's own header. * The five-hour window for the machine this session runs on, under the session's own header.
* *
* Here rather than only in the usage dialog because it is the number that decides whether to keep * Here rather than only in the usage dialog because it is the number that decides whether to keep
* going, and it was a screen away from the place that decision gets made. It reports on this * going, and it was a screen away from the place that decision gets made. It reports on this
@@ -161,10 +159,16 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal // rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved // value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
// would sit at a stale figure for hours. // would sit at a stale figure for hours.
val now = rememberUsageNow() var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(REFRESH_MS)
now = OffsetDateTime.now()
}
}
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report // Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// a problem about a machine somebody chose, on every screen, forever. // a problem about a setup somebody chose, on every screen, forever.
// //
// And nothing while the first fetch is out, which is a different silence. A request in flight // And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this // is not a state to report -- and the session that meters nothing is exactly the one this
@@ -185,15 +189,22 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
// Both handled above, before the row exists at all. // Both handled above, before the row exists at all.
SessionUsage.NotMetered, SessionUsage.NotMetered,
SessionUsage.Waiting -> Unit SessionUsage.Waiting -> Unit
is SessionUsage.Unavailable -> UsageNote("Usage unknown -- ${state.why}") is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
is SessionUsage.Known -> { is SessionUsage.Known -> {
val window = shortestUsageWindow(state.windows) val window = state.windows.firstOrNull { it.kind == "session" }
if (window == null) { if (window == null) {
UsageNote("Usage unknown -- no window duration was reported") UsageNote("5-hour usage unknown -- no five-hour window reported")
} else { } else {
UsageProgressIndicator(window, now, Modifier.weight(1f)) LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
// The same step at the same percentages as the dialog's bars: this is the
// same measurement, and a reader who learned the colour there has to be
// able to read it here without checking which screen they are on.
color = quotaColor(window.percent),
modifier = Modifier.weight(1f),
)
Text( Text(
usageWindowLabel(window, now), fiveHourLabel(window, now),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
@@ -204,56 +215,6 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
} }
} }
/** A clock shared by each usage surface, advanced independently of changes to the quota. */
@Composable
internal fun rememberUsageNow(): OffsetDateTime {
var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(REFRESH_MS)
now = OffsetDateTime.now()
}
}
return now
}
/** The quota fill with a white tick showing how far the current time window has progressed. */
@Composable
internal fun UsageProgressIndicator(
window: UsageWindow,
now: OffsetDateTime,
modifier: Modifier = Modifier,
) {
val elapsed = usageWindowElapsedFraction(window, now)
LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
// The same step at the same percentages everywhere: this is the same measurement, and a
// reader who learned the colour on one surface should not have to relearn it on another.
color = quotaColor(window.percent),
modifier =
modifier.drawWithContent {
drawContent()
elapsed?.let { fraction ->
drawLine(
color = Color.White,
start = Offset(size.width * fraction, 0f),
end = Offset(size.width * fraction, size.height),
strokeWidth = 2.dp.toPx(),
)
}
},
)
}
/** Elapsed time divided by the reported window duration, or null when either value is unknown. */
internal fun usageWindowElapsedFraction(window: UsageWindow, now: OffsetDateTime): Float? {
val durationMinutes = window.durationMinutes?.takeIf { it > 0 } ?: return null
val end = windowEnd(window.resetsAt, now) as? WindowEnd.Ends ?: return null
val remainingMinutes =
end.until.seconds.toDouble() / 60.0 + end.until.nano.toDouble() / 60_000_000_000.0
return (1.0 - remainingMinutes / durationMinutes).coerceIn(0.0, 1.0).toFloat()
}
/** Anything this row says instead of drawing a bar, so all of them look the same. */ /** Anything this row says instead of drawing a bar, so all of them look the same. */
@Composable @Composable
private fun UsageNote(text: String) { private fun UsageNote(text: String) {
@@ -265,40 +226,31 @@ private fun UsageNote(text: String) {
} }
/** /**
* "42% -- 2h 15m left / 5h": how much is gone, then how long what is left has to last, then how * "42% -- 2h 15m left": how much is gone, then how long what is left has to last.
* long the whole window is.
* *
* The percentage on its own does not answer the question it gets asked, which is whether to start * 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. * something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
* *
* The window's *length* is what the provider's own name for it used to carry ("5-hour window"), and
* it is worth more beside the time left than in front of the percentage: "3h 42m left / 5h" says in
* one reading both how much of the cycle is to come and which cycle this is. Where the provider
* reported no duration there is simply nothing after the span -- the name it gave is not a
* measurement of one, so nothing is inferred from it.
*
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window * 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. * that is not running gets the percentage and nothing else.
*/ */
private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String { private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%" val percent = "${window.percent.toInt()}%"
val outOf =
window.durationMinutes?.takeIf { it > 0 }?.let { " / ${formatMillis(it * 60_000)}" } ?: ""
return when (val end = windowEnd(window.resetsAt, now)) { return when (val end = windowEnd(window.resetsAt, now)) {
// Between blocks a window can have no reset time, and saying so is a fact about nothing: // Between blocks the five-hour window has no reset time, and saying so is a fact about
// there is no window to run out. The percentage is the whole answer. // nothing: there is no window to run out. The percentage is the whole answer.
WindowEnd.NotRunning -> percent WindowEnd.NotRunning -> percent
WindowEnd.Unreadable -> "$percent · reset time unreadable" WindowEnd.Unreadable -> "$percent · reset time unreadable"
is WindowEnd.Ends -> is WindowEnd.Ends ->
// Under a minute, including past the end: the number would round to "0m left", which // Under a minute, including past the end: the number would round to "0m left", which
// reads as a measurement rather than as the window having run out. // reads as a measurement rather than as the window having run out.
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon" if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
else "$percent · ${formatSpan(end.until)} left$outOf" else "$percent · ${formatSpan(end.until)} left"
} }
} }
/** /**
* One meter's snapshot, out of every machine's: [machine]'s row for [provider]. * One meter's snapshot, out of every machine's: [setup]'s row for [provider].
* *
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's * Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one * account and, while a test has one set, an echo session's invented one -- and a snapshot is one
@@ -308,60 +260,14 @@ private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the * 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. * machine having no quota rather than the question going unanswered.
*/ */
fun usageFor( fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
snapshots: List<UsageSnapshot>,
machine: String,
provider: String,
model: String?,
): SessionUsage {
// No snapshot at all means the backend never asked, which it only does where there is nothing // No snapshot at all means the backend never asked, which it only does where there is nothing
// to ask about. That is a different answer from having asked and failed. // to ask about. That is a different answer from having asked and failed.
val pools = usageSnapshotsFor(snapshots, machine, provider)
if (pools.isEmpty()) return SessionUsage.NotMetered
val mine = val mine =
usagePoolFor(pools, model) snapshots.firstOrNull { it.setup == setup && it.provider == provider }
?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses") ?: return SessionUsage.NotMetered
if (mine.state != "ok") { if (mine.state != "ok") {
val why = return SessionUsage.Unavailable(mine.detail ?: mine.state)
mine.detail
?: when (mine.state) {
"notLoggedIn" -> "no Claude account is signed in on this machine"
"authenticating" -> "Claude sign-in is in progress"
"loginRequired" -> "Claude sign-in is required"
else -> mine.state
}
return SessionUsage.Unavailable(why)
} }
return SessionUsage.Known(mine.windows) return SessionUsage.Known(mine.windows)
} }
/** Every billing pool reported for one provider on one machine. */
internal fun usageSnapshotsFor(
snapshots: List<UsageSnapshot>,
machine: String,
provider: String?,
): List<UsageSnapshot> =
if (provider == null) emptyList()
else snapshots.filter { it.machine == machine && it.provider == provider }
/** The pool an explicit model names, or the provider's generic pool for every other model. */
internal fun usagePoolFor(pools: List<UsageSnapshot>, model: String?): UsageSnapshot? {
if (pools.size == 1) return pools.first()
val normalizedModel = model?.normalizedPoolName()
val named = normalizedModel?.let { wanted ->
pools.firstOrNull { pool ->
val name = pool.limitName?.normalizedPoolName()
name == wanted || (wanted.contains("luna") && name == "gptreserve")
}
}
return named ?: pools.firstOrNull { it.limitId == "codex" }
}
/** The shortest cycle the selected pool actually reported. */
internal fun shortestUsageWindow(windows: List<UsageWindow>): UsageWindow? =
windows
.mapNotNull { window -> window.durationMinutes?.let { duration -> duration to window } }
.minByOrNull { it.first }
?.second
private fun String.normalizedPoolName(): String = lowercase().filter(Char::isLetterOrDigit)
@@ -1,7 +1,5 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -12,7 +10,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -27,11 +24,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -45,25 +37,19 @@ import kotlinx.coroutines.withContext
* which is what keeps the enrolled token from being able to introduce commands. * which is what keeps the enrolled token from being able to introduce commands.
*/ */
@Composable @Composable
fun MachinesScreen( fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
settings: ServerSettings,
reloadToken: Int,
/** Opens one provider on one machine -- its settings, and what its server is holding. */
onProvider: (String, String) -> Unit,
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) } var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var adding by remember { mutableStateOf(false) } var adding by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<Machine?>(null) } var renaming by remember { mutableStateOf<Setup?>(null) }
var confirmingDelete by remember { mutableStateOf<Machine?>(null) } var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
var signingIn by remember { mutableStateOf<Pair<Machine, Provider>?>(null) }
var busy by remember { mutableStateOf<String?>(null) } var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) } var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() { suspend fun reload() {
state = state =
try { try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) } withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
@@ -96,19 +82,19 @@ fun MachinesScreen(
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> is LoadState.Loaded ->
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
uniqueItems(current.value, key = { it.id }) { machine -> uniqueItems(current.value, key = { it.id }) { setup ->
MachineCard( SetupCard(
machine = machine, setup = setup,
onRename = { renaming = machine }, onRename = { renaming = setup },
onRediscover = { onRediscover = {
scope.launch { scope.launch {
busy = "Asking ${machine.name} what it has…" busy = "Asking ${setup.name} what it has…"
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
updateMachine( updateSetup(
settings, settings,
machine.id, setup.id,
rediscover = true, rediscover = true,
) )
} }
@@ -119,9 +105,7 @@ fun MachinesScreen(
reload() reload()
} }
}, },
onDelete = { confirmingDelete = machine }, onDelete = { confirmingDelete = setup },
onSignIn = { provider -> signingIn = machine to provider },
onProvider = { provider -> onProvider(machine.id, provider.name) },
) )
} }
} }
@@ -129,7 +113,7 @@ fun MachinesScreen(
} }
if (adding) { if (adding) {
AddMachineDialog( AddSetupDialog(
onDismiss = { adding = false }, onDismiss = { adding = false },
onAdd = { name, ssh -> onAdd = { name, ssh ->
adding = false adding = false
@@ -137,7 +121,7 @@ fun MachinesScreen(
busy = "Asking $name what it has…" busy = "Asking $name what it has…"
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { addMachine(settings, name, ssh) } withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
} }
.exceptionOrNull() .exceptionOrNull()
?.message ?.message
@@ -145,13 +129,13 @@ fun MachinesScreen(
reload() reload()
} }
}, },
onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } }, onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
) )
} }
renaming?.let { machine -> renaming?.let { setup ->
RenameDialog( RenameDialog(
machine = machine, setup = setup,
onDismiss = { renaming = null }, onDismiss = { renaming = null },
onRename = { name -> onRename = { name ->
renaming = null renaming = null
@@ -159,7 +143,7 @@ fun MachinesScreen(
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
updateMachine(settings, machine.id, name = name) updateSetup(settings, setup.id, name = name)
} }
} }
.exceptionOrNull() .exceptionOrNull()
@@ -170,10 +154,10 @@ fun MachinesScreen(
) )
} }
confirmingDelete?.let { machine -> confirmingDelete?.let { setup ->
AlertDialog( AlertDialog(
onDismissRequest = { confirmingDelete = null }, onDismissRequest = { confirmingDelete = null },
title = { Text("Remove \"${machine.name}\"?") }, title = { Text("Remove \"${setup.name}\"?") },
text = { text = {
Text( Text(
"The machine is left alone -- this only stops this app offering it. " + "The machine is left alone -- this only stops this app offering it. " +
@@ -187,9 +171,7 @@ fun MachinesScreen(
scope.launch { scope.launch {
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
deleteMachine(settings, machine.id)
}
} }
.exceptionOrNull() .exceptionOrNull()
?.message ?.message
@@ -205,99 +187,34 @@ fun MachinesScreen(
}, },
) )
} }
signingIn?.let { (machine, provider) ->
ProviderLoginDialog(
settings = settings,
machineId = machine.id,
machineName = machine.name,
provider = provider.name,
onDismiss = { signingIn = null },
onSignedIn = {
signingIn = null
scope.launch { reload() }
},
)
}
} }
@Composable @Composable
private fun MachineCard( private fun SetupCard(
machine: Machine, setup: Setup,
onRename: () -> Unit, onRename: () -> Unit,
onRediscover: () -> Unit, onRediscover: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onSignIn: (Provider) -> Unit,
onProvider: (Provider) -> Unit,
) { ) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(machine.name, style = MaterialTheme.typography.titleSmall) Text(setup.name, style = MaterialTheme.typography.titleSmall)
Text( Text(
// Not "this machine": the seeded machine is *called* that, and the card read "this // Not "this machine": the seeded setup is *called* that, and the card read "this
// machine / this machine". // machine / this machine".
machine.address ?: "runs where the backend does", setup.address ?: "runs where the backend does",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
if (machine.providers.isEmpty()) {
Text( Text(
"Nothing found on it. Install something and rediscover.", if (setup.providers.isEmpty()) {
style = MaterialTheme.typography.bodySmall, "Nothing found on it. Install something and rediscover."
)
} else { } else {
machine.providers.forEach { provider -> setup.providers.joinToString(" · ") { it.name }
// A card of its own rather than a line of text: a provider is where the },
// settings that belong to *this machine* live -- how each of its models is
// loaded, the models themselves, and the server holding them -- and those had
// nowhere to be until one llama-server came to serve every session on a
// machine. Sized by its own padding rather than by whatever control happened
// to be on its row, like the tool call cards it is built after.
Card(
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onProvider(provider) }
.semantics { contentDescription = "Open ${provider.name}" },
// A border, and the machine card's own surface kept underneath it.
// The tint that was here before is one step along the surface ladder
// from the card it sits in, and two adjacent surfaces render as one flat
// block: these read as lines of text in a box rather than as things to
// open. One cue, and a visible one.
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
Column(Modifier.weight(1f)) {
Text(provider.name, style = MaterialTheme.typography.titleSmall)
// What was actually found, which is the honest second line and
// the one thing here nobody can change. No arrow: a card that
// lifts off the one behind it already reads as something to open,
// and the chevron was the only thing making these look like rows
// of a list.
provider.command?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// A program is identified by its name, which is the tail
// of its path.
overflow = TextOverflow.StartEllipsis,
) )
}
}
if (provider.kind == "claude_cli") {
TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") }
}
}
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onRename) { Text("Rename") } TextButton(onClick = onRename) { Text("Rename") }
TextButton(onClick = onRediscover) { Text("Rediscover") } TextButton(onClick = onRediscover) { Text("Rediscover") }
@@ -309,7 +226,7 @@ private fun MachineCard(
} }
@Composable @Composable
private fun AddMachineDialog( private fun AddSetupDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAdd: (String, SshDetails?) -> Unit, onAdd: (String, SshDetails?) -> Unit,
onTest: suspend (SshDetails?) -> List<Provider>, onTest: suspend (SshDetails?) -> List<Provider>,
@@ -432,8 +349,8 @@ private fun AddMachineDialog(
} }
@Composable @Composable
private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) { private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(machine.name) } var name by remember { mutableStateOf(setup.name) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Rename") }, title = { Text("Rename") },
@@ -1,250 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.absoluteValue
import kotlinx.coroutines.launch
private const val OPEN_THRESHOLD = 0.35f
private val FLING_THRESHOLD = 400.dp
/**
* How much of the screen a panel takes by default, leaving a sliver of what it is over.
*
* A panel given the whole width instead is standing in for the screen rather than sitting over it,
* and then the sliver would be a strip of a screen the reader has just left behind.
*/
const val PANEL_FRACTION = 0.88f
/** How dark the scrim over [SidePanels]' content goes with a panel fully open. */
private const val SCRIM_ALPHA = 0.32f
/**
* Which side of the content a panel comes in from: where it sits, and which way it slides out.
*
* [sign] is also the direction of the reveal this side owns, so the drag arithmetic is written once
* rather than once per side with the minus signs moved around.
*/
enum class PanelSide(val alignment: Alignment, val sign: Float) {
Left(Alignment.CenterStart, -1f),
Right(Alignment.CenterEnd, 1f),
}
/**
* Keeps [content] composed while a panel belonging to it moves over from the left or the right.
*
* One gesture drives both sides rather than one handler each, because two `draggable`s over the
* same content cannot share a horizontal drag: the inner one claims it whichever way the finger
* went, and the outer never sees a thing. So the position is a single signed reveal -- negative is
* the left panel showing, positive the right -- which also makes it impossible to have both open.
*
* A side left null has no panel and no gesture toward it -- the reveal cannot travel that way at
* all -- so one composable serves a screen with one panel and a screen with two.
*
* The root drag handler deliberately sits behind descendants. A horizontal scroller consumes its
* drag first, so code blocks, attachments and tool inputs keep their existing gesture. Collapsing
* that content, or starting over any ordinary part of the session, gives the gesture back to the
* panel; Android's own right-edge Back gesture remains untouched.
*/
@Composable
fun SidePanels(
left: (@Composable (active: Boolean, close: () -> Unit) -> Unit)? = null,
leftFraction: Float = PANEL_FRACTION,
right: (@Composable (active: Boolean, close: () -> Unit) -> Unit)? = null,
rightFraction: Float = PANEL_FRACTION,
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
// Which panel the gesture settled on, null for neither. The *settled* side rather than the
// current position, so a panel's contents know they are being looked at while the animation
// is still running.
var opened by remember { mutableStateOf<PanelSide?>(null) }
var dragging by remember { mutableStateOf(false) }
var draggedReveal by remember { mutableFloatStateOf(0f) }
val animatedReveal = remember { Animatable(0f) }
// Read from a draw or layout lambda, never from the composable body: where the panel has got
// to changes every frame of a drag, and a body that reads it recomposes this whole subtree --
// the session included -- once per frame. The booleans below are what composition is allowed
// to know, and each of them changes twice per gesture. (Same rule as the keyboard inset in
// SessionScreen, and found the same way.)
fun revealNow() = if (dragging) draggedReveal else animatedReveal.value
val leftShown by remember { derivedStateOf { revealNow() < 0f } }
val rightShown by remember { derivedStateOf { revealNow() > 0f } }
val engaged = leftShown || rightShown
val flingThreshold = with(LocalDensity.current) { FLING_THRESHOLD.toPx() }
suspend fun startDrag() {
animatedReveal.stop()
draggedReveal = animatedReveal.value
dragging = true
}
// Which panel the reveal belongs to, [bias] breaking the tie at rest -- a drag away from
// nothing is toward whichever panel that direction opens.
fun sideOf(bias: Float): PanelSide? =
when {
draggedReveal < 0f -> PanelSide.Left
draggedReveal > 0f -> PanelSide.Right
bias > 0f -> PanelSide.Left
bias < 0f -> PanelSide.Right
else -> null
}
suspend fun finishDrag(velocity: Float) {
val side = sideOf(0f)
// How fast the finger is moving toward that side's open position: the left panel opens
// rightwards and the right panel leftwards, so the sign of a velocity only means something
// once it is read against the side. A fling decides on its own; anything slower is decided
// by how far in the panel already is.
val toward = side?.let { -it.sign * velocity } ?: 0f
val opens =
if (toward.absoluteValue > flingThreshold) toward > 0f
else draggedReveal.absoluteValue >= OPEN_THRESHOLD
val target = side.takeIf { opens }
opened = target
animatedReveal.snapTo(draggedReveal)
dragging = false
animatedReveal.animateTo(target?.sign ?: 0f)
}
fun close() {
opened = null
scope.launch { animatedReveal.animateTo(0f) }
}
BackHandler(enabled = opened != null) { close() }
BoxWithConstraints(Modifier.fillMaxSize()) {
val dragState = rememberDraggableState { delta ->
// Against the width of the panel this drag is moving, since the reveal is a fraction
// of it and the two sides need not be the same width.
val width =
sideOf(delta)?.let {
constraints.maxWidth * if (it == PanelSide.Left) leftFraction else rightFraction
} ?: return@rememberDraggableState
draggedReveal =
(draggedReveal - delta / width.coerceAtLeast(1f)).coerceIn(
if (left == null) 0f else -1f,
if (right == null) 0f else 1f,
)
}
val drag =
Modifier.draggable(
state = dragState,
orientation = Orientation.Horizontal,
onDragStarted = { startDrag() },
onDragStopped = { velocity -> finishDrag(velocity) },
)
Box(
Modifier.fillMaxSize()
.then(drag)
.then(if (engaged) Modifier.clearAndSetSemantics {} else Modifier)
) {
content()
}
if (engaged) {
Box(
Modifier.fillMaxSize()
.graphicsLayer { alpha = revealNow().absoluteValue * SCRIM_ALPHA }
.background(MaterialTheme.colorScheme.scrim)
.semantics { contentDescription = "Dismiss panel" }
.clickable { close() }
)
}
// Both panels stay composed while they are off screen, so opening one costs no
// composition -- but an off-screen panel is cleared from the semantics tree, since nothing
// a reader cannot see should be reachable by swiping through the screen.
left?.let { panel ->
SlidingPanel(
side = PanelSide.Left,
width = maxWidth * leftFraction,
raised = leftFraction < 1f,
shown = { (-revealNow()).coerceAtLeast(0f) },
visible = leftShown,
drag = drag,
) {
panel(opened == PanelSide.Left, ::close)
}
}
right?.let { panel ->
SlidingPanel(
side = PanelSide.Right,
width = maxWidth * rightFraction,
raised = rightFraction < 1f,
shown = { revealNow().coerceAtLeast(0f) },
visible = rightShown,
drag = drag,
) {
panel(opened == PanelSide.Right, ::close)
}
}
}
}
/**
* One panel at [shown] of the way in, sliding out to its own [side].
*
* [raised] is for a panel with some of the screen still beside it, which takes a tonal step to say
* it is above what it has not covered. A panel covering the whole width has nothing to be above,
* and a step there is a screen that is simply the wrong colour.
*
* [visible] says the same thing as `shown() > 0f` and is the form composition may read; see
* [SidePanels].
*/
@Composable
private fun BoxScope.SlidingPanel(
side: PanelSide,
width: Dp,
raised: Boolean,
shown: () -> Float,
visible: Boolean,
drag: Modifier,
contents: @Composable () -> Unit,
) {
Surface(
tonalElevation = if (raised) 3.dp else 0.dp,
shadowElevation = 8.dp,
modifier =
Modifier.align(side.alignment)
.width(width)
.fillMaxHeight()
.graphicsLayer { translationX = side.sign * size.width * (1f - shown()) }
.then(if (visible) Modifier else Modifier.clearAndSetSemantics {})
.then(drag),
) {
contents()
}
}
@@ -48,22 +48,19 @@ fun SpawnScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// What the form is made of, and whether we have it yet. A failure here is not the same as a // 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. // server with nothing to offer, so it must not reach the pickers as empty lists.
var options by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) } var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
// Machine first, then one of its providers. Choosing a machine can invalidate the provider, so // Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the
// the // provider is stored by name and resolved against the current setup rather than held as an
// provider is stored by name and resolved against the current machine rather than held as an
// object that could outlive the list it came from. // object that could outlive the list it came from.
var machineName by remember { mutableStateOf<String?>(null) } var setupName by remember { mutableStateOf<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) } var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") } var model by remember { mutableStateOf("") }
var providerModels by remember { mutableStateOf<List<OfferedModel>>(emptyList()) }
var providerModelsLoading by remember { mutableStateOf(false) }
var providerModelsError by remember { mutableStateOf<String?>(null) }
var cwd by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") }
// Set only after the selected provider reports its own default. An empty value is not sent. // "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
var permissionMode by remember { mutableStateOf("") } // answering "allow Bash?" dozens of times per task is what this app exists to avoid.
var permissionMode by remember { mutableStateOf("auto") }
// Null until the server has been asked, and null again if it answers "no level chosen" -- the // Null until the server has been asked, and null again if it answers "no level chosen" -- the
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer // two are told apart by [defaultsAsked], because a picker that shows a level before the answer
// arrives is one you can spawn at without having chosen it. // arrives is one you can spawn at without having chosen it.
@@ -73,12 +70,17 @@ fun SpawnScreen(
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-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. // form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) } var spawnError by remember { mutableStateOf<String?>(null) }
// Whatever the chosen provider says it takes, by key. Empty until something is typed: an // The models on the *chosen machine*, for a llama provider to choose between. Kept separate
// absent key means the server's own default, which is what every field's placeholder says. // from the setups: a Claude session needs none, so failing to list them must not stop the
var params by remember { mutableStateOf<Map<String, String>>(emptyMap()) } // screen rendering. Refetched when the machine changes, because a model is a file on one
// machine -- see [fetchSetupModels].
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") }
var temperature by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Separate from the machines fetch below and deliberately not fatal: failing to learn the // Separate from the setups fetch below and deliberately not fatal: failing to learn the
// default must leave a screen you can still spawn from, so the picker stays on "default" // default must leave a screen you can still spawn from, so the picker stays on "default"
// and says so rather than the whole form refusing to draw. // and says so rather than the whole form refusing to draw.
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } } runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
@@ -86,9 +88,9 @@ fun SpawnScreen(
defaultsAsked = true defaultsAsked = true
options = options =
try { try {
val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) } val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
val first = fetched.firstOrNull() val first = fetched.firstOrNull()
machineName = first?.name setupName = first?.name
providerName = first?.providers?.firstOrNull()?.name providerName = first?.providers?.firstOrNull()?.name
LoadState.Loaded(fetched) LoadState.Loaded(fetched)
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -110,7 +112,7 @@ fun SpawnScreen(
// Nothing below is fillable until the options are here, and a failure to fetch them leaves // 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 // no form worth showing -- so this reports and stops, rather than offering empty pickers
// under an error message. // under an error message.
val machines = val setups =
when (val state = options) { when (val state = options) {
is LoadState.Loading -> { is LoadState.Loading -> {
CircularProgressIndicator() CircularProgressIndicator()
@@ -122,88 +124,62 @@ fun SpawnScreen(
} }
is LoadState.Loaded -> state.value is LoadState.Loaded -> state.value
} }
val machine = machines.firstOrNull { it.name == machineName } val setup = setups.firstOrNull { it.name == setupName }
val current = machine?.providers?.firstOrNull { it.name == providerName } // Whichever machine is chosen now, asked again when that changes. The old machine's list
// Coding CLIs take a working directory, model, permission mode and thinking level. Keying // is dropped first rather than left on screen: a file name from another machine looks
// the extra fields on the kind rather than the provider name keeps a second installation // exactly like one from this one.
LaunchedEffect(setup?.id) {
models = emptyList()
modelKey = null
val id = setup?.id ?: return@LaunchedEffect
models =
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
.getOrDefault(emptyList())
}
val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and permission modes; keying the
// extra fields on the kind rather than the provider name keeps a second Claude provider
// from needing anything here. // from needing anything here.
val isClaude = current?.kind == "claude_cli" val isClaude = current?.kind == "claude_cli"
val isCodex = current?.kind == "codex_cli"
val isCodingCli = isClaude || isCodex
val isLlama = current?.kind == "llama_cpp" val isLlama = current?.kind == "llama_cpp"
// Echo is the only kind with nothing to choose between.
val offersModels = isCodingCli || isLlama
// Where a session's tools act, which is the only thing a working directory decides.
val takesCwd = isCodingCli || isLlama
// Whichever machine and provider are chosen now, asked again when either changes. The
// previous answer is dropped first rather than left on screen: a model name from another
// machine looks exactly like one from this one.
LaunchedEffect(machine?.id, current?.name) {
model = ""
// A key from the previous provider would be a setting this one does not have, drawn
// by no control and sent at the spawn anyway.
params = emptyMap()
providerModels = emptyList()
providerModelsError = null
permissionMode = current?.defaultPermissionMode.orEmpty()
// Every kind that offers models at all, not only the coding CLIs: a llama provider
// answers with the GGUFs on the machine it runs on, through the same call. One
// question with one answer is what keeps the picker free of a branch on the kind.
if (machine == null || current == null || !offersModels) {
providerModelsLoading = false
return@LaunchedEffect
}
providerModelsLoading = true
try {
providerModels =
withContext(Dispatchers.IO) {
fetchProviderModels(settings, machine.id, current.name)
}
} catch (e: ApiException) {
providerModelsError = e.message
} finally {
providerModelsLoading = false
}
}
// The machine first, because it decides what can be run at all. // The machine first, because it decides what can be run at all.
ChipGroup( ChipGroup(
label = "Machine", label = "Setup",
options = machines.map { it.name }, options = setups.map { it.name },
selected = machineName, selected = setupName,
onSelect = { name -> onSelect = { name ->
machineName = name setupName = name
// The provider list changes with the machine, so a name carried over from the // 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 // previous one would be a selection that isn't in the picker. Take that machine's
// first. // first.
providerName = providerName =
machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
}, },
) )
machine?.address?.let { setup?.address?.let {
Text( Text(
it, it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// The address belongs to the machine above it, not to the provider label below; without // The address belongs to the setup above it, not to the provider label below; without
// this they read as one block. // this they read as one block.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
// Only what this machine actually has. A machine with none says so rather than showing an // Only what this machine actually has. A setup with none says so rather than showing an
// empty row that reads as a failure. // empty row that reads as a failure.
if (machine != null && machine.providers.isEmpty()) { if (setup != null && setup.providers.isEmpty()) {
Text( Text(
"\"${machine.name}\" has no providers configured.", "\"${setup.name}\" has no providers configured.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
ChipGroup( ChipGroup(
label = "Provider", label = "Provider",
options = machine?.providers?.map { it.name }.orEmpty(), options = setup?.providers?.map { it.name }.orEmpty(),
selected = providerName, selected = providerName,
onSelect = { providerName = it }, onSelect = { providerName = it },
) )
@@ -219,59 +195,59 @@ fun SpawnScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (offersModels) { if (isLlama) {
when { // A llama session names one of the models on the machine it will run on, so the
providerModelsLoading -> // choice is that list rather than free text -- a name that is not on that machine's
// disk is a session that cannot start.
if (models.isEmpty()) {
Text( Text(
"Loading model choices…", "No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
providerModelsError != null ->
Text(
"Model choices unavailable: $providerModelsError",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
// A llama session cannot start without one, so this says what to do about it
// rather than only that there is nothing -- the models it needs are on the
// machine that will serve them, which is not always this backend.
providerModels.isEmpty() && isLlama ->
Text(
"No models on ${machine.name}. The Models screen downloads " +
"to the backend; another machine needs the file put there itself.", "to the backend; another machine needs the file put there itself.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
providerModels.isEmpty() -> } else {
Text(
"This machine reported no selectable models.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> {
Spacer(Modifier.height(16.dp))
ChipGroup( ChipGroup(
label = "Model", label = "Model",
// The label, and the id is what is sent: for a llama model those differ, // The file, not the whole key: the repository is the same for every
// since it is chosen by path and named by what is inside the file. // quantisation of a model, so the file name is what tells two of them apart.
options = providerModels.map { it.label }, options = models.map { it.file },
selected = providerModels.firstOrNull { it.id == model }?.label, selected = models.firstOrNull { it.key == modelKey }?.file,
onSelect = { chosen -> onSelect = { file -> modelKey = models.first { it.file == file }.key },
val id = providerModels.first { it.label == chosen }.id
// A llama session has to have one, so choosing the same chip twice
// must not clear it -- there is nothing to fall back to.
model = if (model == id && !isLlama) "" else id
},
) )
} }
} Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = contextSize,
onValueChange = { contextSize = it },
label = { Text("Context size (blank = the model's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = temperature,
onValueChange = { temperature = it },
label = { Text("Temperature (blank = llama.cpp's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }
if (isCodingCli) { if (isClaude) {
// Free text as well as the chips above: the catalog is a shortcut, and a CLI will if (current.models.isNotEmpty()) {
// take a name it did not list. Spacer(Modifier.height(16.dp))
ChipGroup(
label = "Model",
options = current.models,
selected = model.ifEmpty { null },
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
)
}
Spacer(Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = model, value = model,
onValueChange = { model = it }, onValueChange = { model = it },
@@ -280,20 +256,7 @@ fun SpawnScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
}
// Nothing is running yet, so nothing here waits for a restart -- every one of these is
// read by the process this form is about to start.
ProviderParamFields(
specs = current?.params.orEmpty(),
values = params,
onChange = { params = it },
warnAboutRestart = false,
)
// Every session whose tools act on files needs one, which is both kinds that have
// tools -- a llama session's built-in tools run in it exactly as a CLI's do.
if (takesCwd) {
OutlinedTextField( OutlinedTextField(
value = cwd, value = cwd,
onValueChange = { cwd = it }, onValueChange = { cwd = it },
@@ -303,21 +266,15 @@ fun SpawnScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
}
// Offered wherever the provider has modes, rather than where this screen believes it
// does: the server is what knows, and llama.cpp grew them without this line changing.
if (current != null && current.permissionModes.isNotEmpty()) {
ChipGroup( ChipGroup(
label = "Permissions", label = "Permissions",
options = current.permissionModes, options = PERMISSION_MODES,
selected = permissionMode, selected = permissionMode,
onSelect = { permissionMode = it }, onSelect = { permissionMode = it },
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
}
if (isCodingCli) {
// Says what it does to *later* spawns as well, because it does: the level chosen here // Says what it does to *later* spawns as well, because it does: the level chosen here
// is stored as the default, which is the whole way that default is set. A picker that // is stored as the default, which is the whole way that default is set. A picker that
// quietly changed a global would be the same control with the fact left out. // quietly changed a global would be the same control with the fact left out.
@@ -350,25 +307,37 @@ fun SpawnScreen(
// an intent about new sessions in general, so a spawn that then // an intent about new sessions in general, so a spawn that then
// fails must not also lose the choice. Non-fatal for the same // fails must not also lose the choice. Non-fatal for the same
// reason the fetch above is -- the session is what was asked for. // reason the fetch above is -- the session is what was asked for.
if (isCodingCli) { if (isClaude) {
runCatching { setDefaultEffort(settings, effort) } runCatching { setDefaultEffort(settings, effort) }
} }
spawnSession( spawnSession(
settings, settings,
// The id, not the label: labels are editable and the server // The id, not the label: labels are editable and the server
// resolves by id. Non-null here, since `chosen` came from // resolves by id. Non-null here, since `chosen` came from
// `machine`'s own provider list. // `setup`'s own provider list.
machine = machine.id, setup = setup.id,
provider = chosen.name, provider = chosen.name,
title = title.trim(), title = title.trim(),
model = model.trim().takeIf { offersModels }, model =
cwd = cwd.trim().takeIf { takesCwd }, if (isLlama) modelKey else model.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { it.isNotEmpty() }, cwd = cwd.trim().takeIf { isClaude },
effort = effort.takeIf { isCodingCli }, permissionMode = permissionMode.takeIf { isClaude },
// Already only the keys somebody set: a field left blank effort = effort.takeIf { isClaude },
// removes its key rather than sending an empty value, so // Sent only when set, so blank means "whatever llama.cpp does
// "blank" reaches the server as "your default". // by default" rather than a zero.
params = params, params =
buildMap {
if (isLlama) {
contextSize
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("contextSize", it) }
temperature
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("temperature", it) }
}
},
) )
} }
onSpawned(spawned) onSpawned(spawned)
@@ -378,8 +347,7 @@ fun SpawnScreen(
} }
} }
}, },
// A llama session names the file to load, so there is nothing to spawn without one. enabled = !busy && current != null && !(isLlama && modelKey == null),
enabled = !busy && current != null && !(isLlama && model.isEmpty()),
) { ) {
Text(if (busy) "Spawning..." else "Spawn") Text(if (busy) "Spawning..." else "Spawn")
} }
@@ -1,339 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.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
import kotlinx.coroutines.withContext
/**
* What a session has running beside the turn you are reading: its background tasks, then its
* subagents, in the panel [SidePanels] slides over it from the right.
*
* [active] is whether the panel is being looked at: the lists are fetched then rather than on
* composition, since the panel is composed for every session whether or not anybody opens it.
*
* [backgroundTasks] is the live count from the session's own event stream, and is what the
* background list is refetched against: a card for work that has since finished is a stale
* measurement drawn as a current one, which is the one thing a list of what is running now must not
* do.
*
* Both lists are items of one lazy column rather than two stacked scrollers, so expanding the
* background section pushes the subagents down without either being able to run off the panel.
*/
@Composable
fun SubagentPanel(
settings: ServerSettings,
summary: SessionSummary,
active: Boolean,
backgroundTasks: Int,
onClose: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
var rows by
remember(summary.id) { mutableStateOf<LoadState<List<SubagentSummary>>>(LoadState.Loading) }
var selected by remember(summary.id) { mutableStateOf(setOf<String>()) }
var deleting by remember(summary.id) { mutableStateOf(setOf<String>()) }
var deleteError by remember(summary.id) { mutableStateOf<String?>(null) }
var confirming by remember(summary.id) { mutableStateOf<List<SubagentSummary>?>(null) }
var refreshToken by remember(summary.id) { mutableIntStateOf(0) }
var background by
remember(summary.id) {
mutableStateOf<LoadState<List<BackgroundTaskSummary>?>>(LoadState.Loading)
}
var backgroundExpanded by remember(summary.id) { mutableStateOf(false) }
LaunchedEffect(active, refreshToken) {
if (!active) return@LaunchedEffect
rows = LoadState.Loading
rows =
try {
val fetched = withContext(Dispatchers.IO) { fetchSubagents(settings, summary.id) }
selected = selected intersect fetched.mapTo(mutableSetOf()) { it.id }
LoadState.Loaded(fetched)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// No reset to Loading on a refetch: the spinner belongs to the first fetch, and one flashed
// over the list at every start and end would blink precisely when something happened.
LaunchedEffect(active, backgroundTasks, refreshToken) {
if (!active || backgroundTasks == 0) return@LaunchedEffect
background =
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchBackgroundTasks(settings, summary.id) }
)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder)
Column(Modifier.fillMaxSize()) {
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
MarkButton("Close panel", onClose) { Chevron(Pointing.Right) }
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
) {
backgroundTaskSection(
count = backgroundTasks,
tasks = background,
expanded = backgroundExpanded,
onToggle = { backgroundExpanded = !backgroundExpanded },
onRetry = { refreshToken++ },
)
item(key = "subagents-heading") { PanelSectionHeading("Subagents") }
when (val state = rows) {
is LoadState.Loading ->
item(key = "subagents-loading") {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
is LoadState.Error ->
item(key = "subagents-error") {
Column {
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = { refreshToken++ }) { Text("Try again") }
}
}
is LoadState.Loaded ->
if (ordered.isNullOrEmpty()) {
item(key = "subagents-empty") {
Text(
"No subagents in this session.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
uniqueItems(ordered, key = { it.id }) { subagent ->
SubagentCard(
subagent = subagent,
selected = subagent.id in selected,
selecting = selected.isNotEmpty(),
deleting = subagent.id in deleting,
onClick = { onOpenSubagent(subagent) },
onSelect = {
selected =
if (subagent.id in selected) selected - subagent.id
else selected + subagent.id
},
)
}
}
}
}
if (selected.isNotEmpty()) {
val picked = ordered.orEmpty().filter { it.id in selected }
SubagentSelectionBar(
picked = picked,
onDelete = { confirming = picked },
modifier = Modifier.padding(horizontal = 16.dp),
)
}
deleteError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
confirming?.let { picked ->
AlertDialog(
onDismissRequest = { confirming = null },
title = {
Text(
if (picked.size == 1) "Delete this subagent?"
else "Delete ${picked.size} subagents?"
)
},
text = {
Text(
(if (picked.size == 1) "\"${picked.first().title}\"\n\n" else "") +
"A subagent's transcript is the only record of what it did: the session " +
"that started it kept just the Task call. Nothing else has a copy, so " +
"this can't be undone. The session itself is untouched."
)
},
confirmButton = {
TextButton(
onClick = {
confirming = null
selected = emptySet()
val ids = picked.map { it.id }
val gone = ids.toSet()
deleting += gone
deleteError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSubagents(settings, summary.id, ids)
gone.forEach {
transcriptCache
.session(TranscriptAddress(summary.id, it))
.purge()
}
}
val loaded = rows
if (loaded is LoadState.Loaded) {
rows =
LoadState.Loaded(loaded.value.filterNot { it.id in gone })
}
} catch (e: ApiException) {
deleteError = e.message ?: "Delete failed"
} finally {
deleting -= gone
}
}
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
)
}
}
private fun subagentOrder(rows: List<SubagentSummary>): List<SubagentSummary> =
rows.sortedWith(
compareByDescending<SubagentSummary> { it.status == "running" }
.thenByDescending { it.lastActivity }
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SubagentCard(
subagent: SubagentSummary,
selected: Boolean,
selecting: Boolean,
deleting: Boolean,
onClick: () -> Unit,
onSelect: () -> Unit,
) {
BusyItem(label = if (deleting) "deleting" else null) {
OutlinedCard(
colors =
if (selected)
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
else CardDefaults.outlinedCardColors(),
modifier =
Modifier.fillMaxWidth()
.combinedClickable(
enabled = !deleting,
onClick = { if (selecting) onSelect() else onClick() },
onLongClick = onSelect,
),
) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun SubagentSelectionBar(
picked: List<SubagentSummary>,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
val running = picked.count { it.status == "running" }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().heightIn(min = 48.dp),
) {
Text(
if (running == 0) "${picked.size} selected" else "$running still running",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDelete, enabled = running == 0) {
Text(
"Delete",
color =
if (running == 0) MaterialTheme.colorScheme.error
else LocalContentColor.current,
)
}
}
}
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@@ -134,20 +134,6 @@ val clearedColor: Color
val awaitingColor: Color val awaitingColor: Color
@Composable get() = Mocha.Peach @Composable get() = Mocha.Peach
/**
* Waiting on itself: the session's turn is over, but a subagent or a backgrounded command it
* started is still going, and it will speak again with nobody having typed anything.
*
* Its own colour rather than [awaitingColor], which is the opposite state -- that one means the
* reader has something to do, and this one means they specifically do not. Not [runningColor]
* either: nothing is being written, and a green "running" on a session that will say nothing for
* ten minutes is the wrong promise. Blue for the same reason [commandColor] is blue -- not stuck,
* but not replying to you either -- and a different blue because that one is the session acting on
* itself rather than getting on with what was asked.
*/
val waitingColor: Color
@Composable get() = Mocha.Sky
/** Approaching a limit -- still fine, worth seeing. */ /** Approaching a limit -- still fine, worth seeing. */
val warningColor: Color val warningColor: Color
@Composable get() = Mocha.Yellow @Composable get() = Mocha.Yellow
@@ -213,8 +199,6 @@ val rawSurface: Color
*/ */
fun catppuccinSyntax(): SyntaxPalette = fun catppuccinSyntax(): SyntaxPalette =
SyntaxPalette( SyntaxPalette(
addition = Mocha.Green,
deletion = Mocha.Red,
keyword = Mocha.Mauve, keyword = Mocha.Mauve,
string = Mocha.Green, string = Mocha.Green,
literal = Mocha.Peach, literal = Mocha.Peach,
@@ -1,79 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* A model's working, shut until somebody asks for it.
*
* Shut by default, like a tool call and a memory note and for the same reason: it is not what the
* session said, and left open it puts the reasoning between the question and the answer -- which on
* a small model is most of the conversation.
*
* The heading is the whole of what the reader gets for free, so it carries the one thing worth
* knowing without opening anything: whether this is still going, and if not how long it took. A
* spinner while it runs, because that is the same fact a running command reports and it is drawn
* the same way here.
*/
@Composable
fun ThinkingCard(
item: TranscriptItem.ThinkingRow,
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(thinkingHeadline(item), style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.width(8.dp))
if (item.open) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
}
}
if (expanded) {
// Plain text rather than markdown: this is a model talking to itself, so its
// half-finished lists and stray backticks are not markup it meant to write, and
// rendering them as such makes the working look like an answer.
Text(
item.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
/**
* "Thinking", "Thought for 12.4s", or "Thought".
*
* The third is the one worth keeping: a block whose turn ended before the model said anything --
* interrupted, stopped, a process that exited -- was thought about for a length of time nobody
* measured. Naming a span there would be this screen inventing one, and the reader has no way to
* tell an invented one from the rest.
*/
fun thinkingHeadline(item: TranscriptItem.ThinkingRow): String =
when {
item.open -> "Thinking"
item.ms != null -> "Thought for ${formatMillis(item.ms)}"
else -> "Thought"
}
@@ -1,6 +1,9 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -48,31 +51,24 @@ data class ToolInput(
private val SUBJECTS: Map<String, Pair<String, Language?>> = private val SUBJECTS: Map<String, Pair<String, Language?>> =
mapOf( mapOf(
"Bash" to ("command" to Language.SHELL), "Bash" to ("command" to Language.SHELL),
"Shell" to ("command" to Language.SHELL),
"Patch" to ("diff" to Language.DIFF),
"Read" to ("file_path" to null), "Read" to ("file_path" to null),
"Write" to ("file_path" to null), "Write" to ("file_path" to null),
"Edit" to ("file_path" to null), "Edit" to ("file_path" to null),
"Glob" to ("pattern" to null), "Glob" to ("pattern" to null),
"Grep" to ("pattern" to null), "Grep" to ("pattern" to null),
"WebFetch" to ("url" to null), "WebFetch" to ("url" to null),
"WebSearch" to ("query" to null),
// Persisted transcripts keep the provider vocabulary they were written with.
"web_search" to ("query" to null),
) )
/** Fields that are the tool's own prose about itself rather than input to it. */ /** Fields that are the tool's own prose about itself rather than input to it. */
private val DESCRIPTIONS = listOf("description", "prompt") private val DESCRIPTIONS = listOf("description", "prompt")
fun parseToolInput(tool: String, input: String): ToolInput { fun parseToolInput(tool: String, input: String): ToolInput {
if (input.trim() == "null") return ToolInput(null, null, null, null, emptyList())
val json = val json =
try { try {
JSONObject(input) JSONObject(input)
} catch (_: org.json.JSONException) { } catch (_: org.json.JSONException) {
// Not an object: older transcripts and some tools send a bare string. It is still the // Not an object: older transcripts and some tools send a bare string. It is still the
// input, so it is still shown. JSON null is the one exception: it means the call had // input, so it is still shown.
// no input, and drawing the word makes an absent value look like an instruction.
return ToolInput( return ToolInput(
null, null,
null, null,
@@ -82,20 +78,15 @@ fun parseToolInput(tool: String, input: String): ToolInput {
) )
} }
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null) val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
val subject = val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() }
subjectKey
?.let { json.text(it) }
?.takeIf { it.isNotBlank() }
?.let { if (tool == "Bash") renderedBashScript(it) ?: it else it }
val description = DESCRIPTIONS.firstNotNullOfOrNull { val description = DESCRIPTIONS.firstNotNullOfOrNull {
json.text(it)?.takeIf { value -> value.isNotBlank() } json.optString(it).takeIf { v -> v.isNotBlank() }
} }
val timeout = json.text("timeout")?.takeIf { it.isNotBlank() }?.let { formatMillisText(it) } val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
val rest = val rest =
json json
.keys() .keys()
.asSequence() .asSequence()
.filterNot(json::isNull)
.filter { it != subjectKey || subject == null } .filter { it != subjectKey || subject == null }
.filter { it !in DESCRIPTIONS || description == null } .filter { it !in DESCRIPTIONS || description == null }
.filter { it != "timeout" || timeout == null } .filter { it != "timeout" || timeout == null }
@@ -105,31 +96,6 @@ fun parseToolInput(tool: String, input: String): ToolInput {
return ToolInput(subject, language, description, timeout, rest) return ToolInput(subject, language, description, timeout, rest)
} }
private fun JSONObject.text(key: String): String? =
if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
/**
* Removes Codex's rendered Bash argv from old transcript rows.
*
* New events arrive normalized by the server, but persisted transcripts keep the input originally
* written to them. Only the outer pair are presentation quoting: quotes inside the command belong
* to the command and must not be parsed as an early end delimiter.
*/
internal fun renderedBashScript(command: String): String? {
val prefix =
listOf("/usr/bin/bash -lc ", "/bin/bash -lc ", "bash -lc ").firstOrNull {
command.startsWith(it)
} ?: return null
val quoted = command.removePrefix(prefix)
return quoted
.takeIf {
it.length >= 2 &&
((it.startsWith('\'') && it.endsWith('\'')) ||
(it.startsWith('"') && it.endsWith('"')))
}
?.substring(1, quoted.lastIndex)
}
/** /**
* A tool call's input: its subject highlighted, then whatever else it carried. * A tool call's input: its subject highlighted, then whatever else it carried.
* *
@@ -147,8 +113,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
RawBlock(modifier) { RawBlock(modifier) {
parsed.subject?.let { subject -> parsed.subject?.let { subject ->
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the // Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely. The sideways scroll that makes that readable is the block's, // one being read closely.
// shared with the lines below -- see [RawBlock].
Text( Text(
// Not cached: a tool's subject is one command line, which lexes in microseconds -- // Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it. // the cache exists for a fence with two hundred lines in it.
@@ -156,6 +121,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = false, softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) )
} }
parsed.rest.forEach { parsed.rest.forEach {
@@ -164,7 +130,6 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
} }
@@ -27,9 +27,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -64,9 +61,7 @@ sealed class TranscriptRow {
* *
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number, * A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
* and it is the *same* value whether the run is drawn as one card or as a group. Which value * 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]) everywhere a row is one thing; where * that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
* [groupRuns] cuts a run into several rows it is the one deciding, and it says so by handing
* each piece its key.
*/ */
abstract val key: Any abstract val key: Any
@@ -80,15 +75,23 @@ sealed class TranscriptRow {
*/ */
abstract val startSeq: Long abstract val startSeq: Long
data class Single(val item: TranscriptItem, override val key: Any = item.key) : data class Single(val item: TranscriptItem) : TranscriptRow() {
TranscriptRow() { override val key: Any
get() = item.key
override val startSeq: Long override val startSeq: Long
get() = item.seq get() = item.seq
} }
/** Two or more calls with nothing between them; drawn as one collapsed card. */ /** Two or more calls with nothing between them; drawn as one collapsed card. */
data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) : data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
TranscriptRow() { /** The run's own name, which every call in it already carries. */
val id: String
get() = calls.first().runId
override val key: Any
get() = id
override val startSeq: Long override val startSeq: Long
get() = calls.first().seq get() = calls.first().seq
} }
@@ -99,75 +102,33 @@ sealed class TranscriptRow {
* *
* A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words, * A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words,
* and the run this exists for is the burst of five greps nobody wants to scroll past. * and the run this exists for is the burst of five greps nobody wants to scroll past.
*
* The last call is left alone too, and so is one still running wherever in its run it sits. What
* the session is doing, or did last, is the one thing worth seeing without opening anything, and a
* heading counting it hides it. What folds a call back into its run is therefore not finishing but
* being overtaken: anything arriving behind it, a reply included, makes it history.
*
* [heldOut] is the one thing being read can change, and only in that direction: a call standing on
* its own that somebody is reading is not overtaken while they read it. Opening a call *already*
* inside a group does not pull it out (2026-09-16, after it briefly did) -- it is visible where it
* is, and grouping is what gives a row its identity, so a rule that reads the open set both ways
* makes the reader's own tap rebuild the rows around it: three rows became one the moment a call
* was closed, and no anchor survives a row that no longer exists -- the list jumped by 450px and
* took the closed card with it. Which calls are held out is [SessionScreen]'s to say, since being
* inside a group once is what settles it.
*/ */
fun groupToolRuns( fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
items: List<TranscriptItem>, DebugStats.timed("grouped tool runs") { groupRuns(items) }
heldOut: Set<String> = emptySet(),
): List<TranscriptRow> = DebugStats.timed("grouped tool runs") { groupRuns(items, heldOut) }
private fun groupRuns(items: List<TranscriptItem>, heldOut: Set<String>): List<TranscriptRow> { private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
val rows = mutableListOf<TranscriptRow>() val rows = mutableListOf<TranscriptRow>()
var run = mutableListOf<TranscriptItem.ToolRun>() var run = mutableListOf<TranscriptItem.ToolRun>()
// A run can occupy more than one non-adjacent piece, so claimed keys span the whole transcript
// rather than resetting at each piece.
var runId: String? = null
val claimedKeys = mutableSetOf<String>()
fun flush() { fun flush() {
val first = run.firstOrNull() ?: return when (run.size) {
// The first piece keeps the run's name, which survives a page landing in front of it 0 -> {}
// ([adoptRun]). Later pieces qualify that name with their first call; the suffix is the 1 -> rows += TranscriptRow.Single(run.first())
// final guard because a duplicate LazyColumn key takes down the whole screen. else -> rows += TranscriptRow.Tools(run.toList())
var key = first.runId
if (!claimedKeys.add(key)) {
key = "${first.runId}/${first.id}"
var suffix = 2
while (!claimedKeys.add(key)) {
key = "${first.runId}/${first.id}/${suffix++}"
} }
}
rows +=
if (run.size == 1) TranscriptRow.Single(first, key)
else TranscriptRow.Tools(run.toList(), key)
run = mutableListOf() run = mutableListOf()
} }
items.forEachIndexed { index, item -> items.forEach { item ->
// Grouped by the run each call says it belongs to, not by adjacency worked out here. // 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 // 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 // arriving next to an existing run, or a page of history arriving in front of one, both
// change which call is *first*. // change which call is *first*.
val call = item as? TranscriptItem.ToolRun if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
if (call == null || call.runId != runId) { run += item
} else {
flush() flush()
runId = call?.runId if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
}
when {
call == null -> rows += TranscriptRow.Single(item)
// Standing outside the run is the call's place in the list as it is now, not something
// recorded on the call: the same finished call is a row of its own while it is the last
// thing that happened, or open and never yet grouped, and part of its group once a
// reply lands behind it.
call.done && call.id !in heldOut && index != items.lastIndex -> run += call
else -> {
flush()
run += call
flush()
}
} }
} }
flush() flush()
@@ -199,15 +160,7 @@ fun ToolGroup(
*/ */
onToggle: () -> Unit, onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean, isToolExpanded: (String) -> Boolean,
/** onToolToggle: (String) -> Unit,
* Toggles one call, and says where in the group it was drawn: how far down the group's own top
* edge its card begins, and how tall that card is now.
*
* The screen anchors on *rows*, and a call is not one -- but what the reader is opening or
* shutting is the call, and keeping it under their finger needs its place inside the row. Only
* the group knows that, so only the group can say it. See `SessionScreen`'s `toggleAnchored`.
*/
onToolToggle: (id: String, top: Int, height: Int) -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit, image: @Composable (String) -> Unit,
) { ) {
@@ -222,10 +175,8 @@ fun ToolGroup(
} }
return return
} }
val placed = remember { Placed() }
Column( Column(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.onPlaced { placed.top = it.positionInRoot().y }
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow) .background(MaterialTheme.colorScheme.surfaceContainerLow)
) { ) {
@@ -245,19 +196,13 @@ fun ToolGroup(
verticalArrangement = Arrangement.spacedBy(GROUP_GAP), verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
) { ) {
group.calls.forEachIndexed { index, call -> group.calls.forEachIndexed { index, call ->
val card = remember(call.id) { Placed() }
ToolCard( ToolCard(
tool = call, tool = call,
expanded = isToolExpanded(call.id), expanded = isToolExpanded(call.id),
onToggle = { onToggle = { onToolToggle(call.id) },
onToolToggle(call.id, (card.top - placed.top).toInt(), card.height)
},
onAnswer = onAnswer, onAnswer = onAnswer,
image = image, image = image,
shape = connectedShape(index, group.calls.size), shape = connectedShape(index, group.calls.size),
modifier =
Modifier.onPlaced { card.top = it.positionInRoot().y }
.onSizeChanged { card.height = it.height },
) )
} }
} }
@@ -267,18 +212,6 @@ fun ToolGroup(
} }
} }
/**
* Where something was last placed, in the window's coordinates, and how tall it was.
*
* Deliberately not snapshot state: it is written from the layout phase, and a write there that
* composition reads would schedule another recomposition of every group on screen, every frame.
* Nothing reads it except the gesture that follows.
*/
private class Placed {
var top = 0f
var height = 0
}
/** /**
* The height of a group's heading, and so of the bar at its foot. * The height of a group's heading, and so of the bar at its foot.
* *
@@ -360,17 +293,14 @@ fun ToolCard(
image: @Composable (String) -> Unit = {}, image: @Composable (String) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */ /** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape, shape: Shape = CardDefaults.shape,
modifier: Modifier = Modifier,
) { ) {
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) } val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val name = toolDisplayName(tool.tool)
val output = toolDisplayOutput(tool.tool, tool.output)
val deciding = tool.asks.any { it.answers.isEmpty() } val deciding = tool.asks.any { it.answers.isEmpty() }
val open = expanded || deciding val open = expanded || deciding
Card(modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) { Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
Column(Modifier.padding(GROUP_INSET_LARGE)) { Column(Modifier.padding(GROUP_INSET_LARGE)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(name, style = MaterialTheme.typography.titleSmall) Text(tool.tool, style = MaterialTheme.typography.titleSmall)
if (open) { if (open) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
parsed.timeout?.let { parsed.timeout?.let {
@@ -425,26 +355,24 @@ fun ToolCard(
if (tool.tool != ASK_USER_QUESTION) { if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp)) ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
} }
if (output.isNotEmpty()) { if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall) Text("Output", style = MaterialTheme.typography.labelSmall)
// What the tool printed, on the surface everything verbatim gets and in the // What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is // face it was written for: this is column-aligned far more often than it is
// prose, and a proportional font silently destroys the alignment that carried // prose, and a proportional font silently destroys the alignment that carried
// the meaning. Unwrapped for the same reason, and scrolled sideways by the // the meaning.
// block around it -- see [RawBlock].
// //
// Its terminal styling applied and the rest of the escapes taken out: colour is // 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 // often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once. // the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() } val palette = remember { ansiPalette() }
val styled = remember(output, palette) { ansiStyled(output, palette) } val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
RawBlock(Modifier.padding(top = 2.dp)) { RawBlock(Modifier.padding(top = 2.dp)) {
Text( Text(
styled, styled,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = false,
) )
} }
} }
@@ -463,22 +391,6 @@ fun ToolCard(
} }
} }
private val collaborationToolNames =
mapOf(
"Task" to "Spawn agent",
"TaskOutput" to "Wait for agents",
"SendMessage" to "Message agent",
"CloseAgent" to "Close agent",
"InterruptAgent" to "Interrupt agent",
"ListAgents" to "List agents",
"ResumeAgent" to "Resume agent",
)
internal fun toolDisplayName(tool: String): String = collaborationToolNames[tool] ?: tool
internal fun toolDisplayOutput(tool: String, output: String): String =
if (tool in collaborationToolNames && output == "completed") "" else output
/** /**
* The permission ask on the call it is about. * The permission ask on the call it is about.
* *
@@ -63,45 +63,6 @@ sealed class TranscriptItem {
* inside that reply would step the list under them. * inside that reply would step the list under them.
*/ */
val settled: Boolean = false, val settled: Boolean = false,
/** A final value that supersedes provisional deltas behind a page boundary. */
val replacesPrefix: Boolean = false,
/**
* When the reply was sent, in epoch seconds: the time on its newest delta, which is the
* moment it finished rather than the moment it started.
*
* The transcript's own timestamp rather than a clock read here, so every device draws the
* same time under the same reply and a replayed page agrees with the live stream.
*/
val ts: Double = 0.0,
/**
* How fast it was generated, where the provider measured it; null everywhere else.
*
* Folded on from the turn's usage event rather than carried by the text, because it is not
* known until the reply is over.
*/
val tokensPerSecond: Double? = null,
/** How long the provider spent reading the prompt, where it measured that. */
val prefillMs: Long? = null,
) : TranscriptItem()
/**
* The model's working before -- or between -- the things it said.
*
* Its own row rather than part of the reply, and deliberately not a [ToolRun]: a run of tool
* calls collapses into one card, and folding a model's reasoning into "Called 6 tools" would
* file it as one of them. Shut by default, like every other card that is not what was said.
*
* Three states, because two of them are not the same absence. [open] is a block still being
* thought, which is what the spinner is for. A closed one with an [ms] says how long it took; a
* closed one without is a block whose turn ended before anything said -- an interrupted reply,
* a session stopped mid-thought -- and it says so by not naming a duration rather than by
* naming a wrong one.
*/
data class ThinkingRow(
override val seq: Long,
val text: String,
val ms: Long? = null,
val open: Boolean = true,
) : TranscriptItem() ) : TranscriptItem()
data class ToolRun( data class ToolRun(
@@ -182,29 +143,6 @@ sealed class TranscriptItem {
get() = arrived get() = arrived
} }
/**
* Where one turn ended and the next began with nothing said in between.
*
* A rule and no words. Two replies meet like this whenever a turn starts without anybody typing
* -- a subagent reporting back, a session the CLI picked up by itself -- and drawn with only
* the ordinary gap between them they read as one answer with a paragraph break through the
* middle of it. What the reader needs is to see that these are two; what started the turn is
* somebody else's transcript's business, and a row per background task is a screenful of
* dividers about work nobody was asking after.
*
* Made by the fold rather than sent by the server, because it is not something that happened:
* it is the boundary between two things that did. See [foldEvent].
*/
data class TurnBreak(override val seq: Long) : TranscriptItem() {
/**
* Its own key, because it shares a [seq] with the reply it sits above -- that reply's first
* delta is the event this was made at, and a keyed list refuses two items with one key by
* taking the app down.
*/
override val key: Any
get() = "break$seq"
}
/** /**
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather * 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 * than only shown while it waits, because it explains what follows: a conversation that
@@ -293,7 +231,7 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
* with the seam wherever the reader happened to have paged. * with the seam wherever the reader happened to have paged.
*/ */
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> { fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitThinking(healSplitMessage(earlier, later)) val (older, newer) = healSplitMessage(earlier, later)
val startedEarlier = val startedEarlier =
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id } older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
val endedLater = val endedLater =
@@ -323,10 +261,9 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
/** /**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate. * Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
* *
* [foldEvent] never leaves an *unfinished* assistant message with another behind it inside one * [foldEvent] never leaves two assistant messages next to each other inside one page, so two
* page, so an unsettled one at a join is always the far half of the reply the boundary cut, and * meeting at a join are always the two halves of one reply, and leaving them apart drew a single
* leaving the two apart drew a single answer as two with a paragraph break through the middle of a * answer as two with a paragraph break through the middle of a sentence.
* sentence. Two settled replies meeting there are two turns and stay two.
* *
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older * 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 * half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
@@ -341,36 +278,6 @@ private fun healSplitMessage(
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) { if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
return earlier to later return earlier to later
} }
// A settled reply is a whole turn, so the two are two answers that happen to meet at the
// boundary rather than one cut in half -- the same distinction the fold makes, and joining them
// here would put back exactly the run-together paragraph it stops.
// The rule between them is put in here too, since the fold that would have made it never saw
// these two side by side.
if (head.settled) return earlier to (listOf(TranscriptItem.TurnBreak(tail.seq)) + later)
if (tail.replacesPrefix) return earlier.dropLast(1) to later
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
}
/**
* Rejoins a thinking block the page boundary cut, the same way [healSplitMessage] rejoins a reply.
*
* A block streams a fragment at a time exactly as a reply does, so a boundary lands inside one as
* readily. The older half then holds an open block whose [SessionEvent.ThinkingDone] is on the
* newer page -- so it spun for the rest of the conversation, saying the machine was working on a
* thought it finished minutes ago, and the same working was drawn as two blocks.
*
* Only where the older half is still open: a closed one has its own ending and the two are two
* blocks that happen to meet here. The newer half keeps its identity, for the reason [adoptRun]
* gives -- it is the part already on screen.
*/
private fun healSplitThinking(
pages: Pair<List<TranscriptItem>, List<TranscriptItem>>
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
val (earlier, later) = pages
val head = earlier.lastOrNull()
val tail = later.firstOrNull()
if (head !is TranscriptItem.ThinkingRow || tail !is TranscriptItem.ThinkingRow) return pages
if (!head.open) return pages
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1)) return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
} }
@@ -457,64 +364,14 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// first of them: a row whose identity changed with every delta would be a new row on // first of them: a row whose identity changed with every delta would be a new row on
// every frame, and the list would jump for the whole of a streamed answer. // every frame, and the list would jump for the whole of a streamed answer.
val last = items.lastOrNull() val last = items.lastOrNull()
// Only into a reply that is still arriving. A settled one is a turn that ended, and if (last is TranscriptItem.AssistantMsg) {
// text after it belongs to the next turn -- a separate message, drawn as its own row. // A message growing again is not finished, whatever a status said in between.
// Growing it instead ran two answers together with not even a space between them, items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
// which is what happens whenever a turn starts with nothing recorded in front of it:
// a subagent reporting back, or a peer message the CLI only owns up to at the end.
if (last is TranscriptItem.AssistantMsg && !last.settled) {
items.dropLast(1) + last.copy(text = last.text + event.delta, ts = entry.ts)
} else { } else {
// A rule between the two, and only where they actually meet: anything that draws a items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
// row of its own -- a message, a command, a peer note -- is already the boundary.
val between =
if (last is TranscriptItem.AssistantMsg)
listOf(TranscriptItem.TurnBreak(entry.seq))
else emptyList()
items + between + TranscriptItem.AssistantMsg(entry.seq, event.delta, ts = entry.ts)
} }
} }
is SessionEvent.AssistantTextFinal -> {
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg && !last.settled) {
items.dropLast(1) +
last.copy(text = event.text, replacesPrefix = true, ts = entry.ts)
} else {
val between =
if (last is TranscriptItem.AssistantMsg)
listOf(TranscriptItem.TurnBreak(entry.seq))
else emptyList()
items +
between +
TranscriptItem.AssistantMsg(
entry.seq,
event.text,
replacesPrefix = true,
ts = entry.ts,
)
}
}
is SessionEvent.Thinking -> {
// Deltas grow the open block, keeping the seq of the first of them, for the same
// reason a reply's do: a row whose identity changed per delta is a new row per frame.
val last = items.lastOrNull()
if (last is TranscriptItem.ThinkingRow && last.open) {
items.dropLast(1) + last.copy(text = last.text + event.delta)
} else {
items + TranscriptItem.ThinkingRow(entry.seq, event.delta)
}
}
// The newest block still open, rather than whatever row happens to be last.
is SessionEvent.ThinkingDone ->
closeThinking(items) { it.copy(ms = event.ms, open = false) }
is SessionEvent.ToolStart -> is SessionEvent.ToolStart ->
// A call id names one call for its whole lifetime. Codex can repeat the start while
// recovering an in-flight item; appending that replay made two rows with one key, and
// Compose aborts the entire LazyColumn when it encounters them. Ignoring the replay
// also repairs transcripts which already contain it when they are folded on reopen.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
items
} else {
items + items +
TranscriptItem.ToolRun( TranscriptItem.ToolRun(
entry.seq, entry.seq,
@@ -525,7 +382,6 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
"", "",
done = false, done = false,
) )
}
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) } is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd -> is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A fold that only ever // Created when its start is not here, rather than dropped. A fold that only ever
@@ -600,10 +456,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// nothing it belongs above. // nothing it belongs above.
is SessionEvent.MessageDropped -> items is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items is SessionEvent.Settings -> items
is SessionEvent.BackgroundTasks -> items
is SessionEvent.Status -> settleReply(items, event.state) is SessionEvent.Status -> settleReply(items, event.state)
is SessionEvent.AuthenticationRequired ->
items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image -> is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of its own when there is // Under the call that produced it when there is one, and a row of its own when there is
@@ -622,27 +475,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
is SessionEvent.Compacted -> is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens) items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]") is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// Said rather than skipped: a line the server could not read is a hole in the conversation, // Screen-level state, not transcript rows -- see SessionScreen.
// and one that draws nothing is a hole nothing on screen ever mentions. is SessionEvent.UsageDelta -> items
is SessionEvent.Unreadable ->
items + TranscriptItem.Note(entry.seq, "[unreadable: ${event.kind}]")
// No row: see [SessionEvent.RetiredTaskNote].
is SessionEvent.RetiredTaskNote -> items
// No row of its own -- the counts are screen-level state, see SessionScreen -- but the
// generation speed belongs under the reply it measured, and this is where that reply ends.
// Only onto the newest row, and only when that row is a reply: a turn whose usage arrives
// after a tool call has nothing here to put it on, which draws as a footer without it.
is SessionEvent.UsageDelta ->
when (val last = items.lastOrNull()) {
is TranscriptItem.AssistantMsg ->
items.dropLast(1) +
last.copy(
tokensPerSecond = event.tokensPerSecond,
prefillMs = event.prefillMs,
)
else -> items
}
is SessionEvent.ContextWindow -> items
} }
/** /**
@@ -653,22 +487,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
*/ */
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> { private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
if (sessionWorking(state)) return items if (sessionWorking(state)) return items
// A block the turn ended in the middle of is over, however it ended. Left open it spins for val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
// the rest of the conversation, which says the machine is working when nothing is. if (last.settled) return items
val ended = closeThinking(items) { it.copy(open = false) } return items.dropLast(1) + last.copy(settled = true)
val last = ended.lastOrNull() as? TranscriptItem.AssistantMsg ?: return ended
if (last.settled) return ended
return ended.dropLast(1) + last.copy(settled = true)
}
/** [change] applied to the newest thinking block still open, if there is one. */
private fun closeThinking(
items: List<TranscriptItem>,
change: (TranscriptItem.ThinkingRow) -> TranscriptItem.ThinkingRow,
): List<TranscriptItem> {
val at = items.indexOfLast { it is TranscriptItem.ThinkingRow && it.open }
if (at < 0) return items
return items.toMutableList().apply { this[at] = change(this[at] as TranscriptItem.ThinkingRow) }
} }
private fun updateTool( private fun updateTool(
@@ -1,7 +1,6 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -11,9 +10,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.text.selection.SelectionState import androidx.compose.foundation.text.selection.SelectionState
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -53,8 +49,6 @@ fun TranscriptList(
units: List<TranscriptUnit>, units: List<TranscriptUnit>,
state: LazyListState, state: LazyListState,
moreHistory: Boolean, moreHistory: Boolean,
historyError: String?,
onRetryHistory: () -> Unit,
selection: SelectionState, selection: SelectionState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
below: @Composable () -> Unit, below: @Composable () -> Unit,
@@ -100,29 +94,15 @@ fun TranscriptList(
DebugStats.count("unit composed") DebugStats.count("unit composed")
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) } Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
} }
// Standing in for everything not fetched yet. A failed fetch stays actionable here: // Standing in for everything not fetched yet. Only here while there is more -- its
// when the loaded transcript is too short to scroll, this boundary is the only place // appearance at the top edge is also roughly when the next page is asked for, so what
// the reader can be given another way to ask. // it reports is a fetch in flight rather than an end reached.
if (moreHistory) { if (moreHistory) {
item(key = "history", contentType = "history") { item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) { Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
if (historyError == null) {
CircularProgressIndicator( CircularProgressIndicator(
Modifier.align(Alignment.Center).size(HISTORY_SPINNER) Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
) )
} else {
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Couldn't load earlier messages. $historyError",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = onRetryHistory) { Text("Try again") }
}
}
} }
} }
} }
@@ -130,25 +130,6 @@ sealed class TranscriptUnit {
get() = "u$seq:$ordinal" get() = "u$seq:$ordinal"
} }
/**
* The line under a finished reply: when it was sent, and how fast it was generated.
*
* A unit of its own rather than something drawn inside the last block, because a settled reply
* *is* its blocks -- there is no row left to hang it on, and the last block is a piece of
* markdown that knows nothing about the message it came from.
*/
data class ReplyFoot(
override val seq: Long,
override val ordinal: Int,
val ts: Double,
val tokensPerSecond: Double?,
val prefillMs: Long?,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "f$seq"
}
/** One memory note of a settled reply; see [MemoryNote]. */ /** One memory note of a settled reply; see [MemoryNote]. */
data class Memory( data class Memory(
override val seq: Long, override val seq: Long,
@@ -256,19 +237,6 @@ fun transcriptUnits(
} }
} }
} }
// Unconditional, because being in this branch is what says the reply is over:
// [splitWanted] is settled-or-overtaken. The case to keep out is a message still
// arriving, whose "sent at" is not yet the one it ends up with, and that is drawn
// whole.
units +=
TranscriptUnit.ReplyFoot(
row.startSeq,
ordinal,
item.ts,
item.tokensPerSecond,
item.prefillMs,
gap(FOOT_SPACING),
)
} else { } else {
units += TranscriptUnit.Whole(row, rowGap) units += TranscriptUnit.Whole(row, rowGap)
} }
@@ -312,14 +280,6 @@ fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<Tra
* length has lines that wrap, so its bubble is at the full width already and the slices match it * length has lines that wrap, so its bubble is at the full width already and the slices match it
* exactly. Below it, one item of at most a few screens is nothing the list minds composing. * exactly. Below it, one item of at most a few screens is nothing the list minds composing.
*/ */
/**
* The room between a reply's last block and the line under it.
*
* Tighter than the gap between blocks: the footer belongs to the message above it, and at a block's
* spacing it reads as a row of its own floating between two replies.
*/
private val FOOT_SPACING: Dp = 2.dp
const val USER_SPLIT_CHARS = 4000 const val USER_SPLIT_CHARS = 4000
/** /**
@@ -415,7 +375,6 @@ private val TranscriptUnit?.kind: String
is TranscriptUnit.PeerBlock -> "peer block" is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice" is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note" is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.ReplyFoot -> "reply footer"
is TranscriptUnit.Whole -> is TranscriptUnit.Whole ->
when (val row = row) { when (val row = row) {
is TranscriptRow.Tools -> "tool group" is TranscriptRow.Tools -> "tool group"
@@ -9,15 +9,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -33,14 +30,7 @@ import java.time.OffsetDateTime
* own, so the only thing its Back could ever have meant was "put this away". * own, so the only thing its Back could ever have meant was "put this away".
*/ */
@Composable @Composable
fun UsageDialog( fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
settings: ServerSettings,
feed: UsageFeed,
session: SessionSummary,
onDismiss: () -> Unit,
) {
var signingIn by remember { mutableStateOf(false) }
val now = rememberUsageNow()
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps // 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; // 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 // this is a dense read-out, and those gaps left a band of empty dialog above Close that was
@@ -55,6 +45,10 @@ fun UsageDialog(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
// Deliberately not subtitled with the provider this was opened from. These
// 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( Text(
"Usage", "Usage",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
@@ -71,24 +65,10 @@ fun UsageDialog(
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Scrolls rather than being trimmed: a machine can report any number of windows and // Scrolls rather than being trimmed: a machine can report any number of windows and
// a provider can report several billing pools, and a dialog is the one place where // there can be any number of machines, and a dialog is the one place where running
// running out of room is silent. `fill = false` so a short read-out keeps a short // out of room is silent. `fill = false` so a short read-out keeps a short dialog.
// dialog.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
val state = UsageBody(feed.snapshots)
when (val snapshots = feed.snapshots) {
is LoadState.Loading -> LoadState.Loading
is LoadState.Error -> snapshots
is LoadState.Loaded ->
LoadState.Loaded(
usageSnapshotsFor(
snapshots.value,
session.machine,
session.usageProvider,
)
)
}
UsageBody(state, now, onSignIn = { signingIn = true })
} }
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
Text("Close") Text("Close")
@@ -96,46 +76,29 @@ fun UsageDialog(
} }
} }
} }
if (signingIn) {
ProviderLoginDialog(
settings = settings,
machineId = session.machine,
machineName = session.machineName,
provider = session.provider,
onDismiss = { signingIn = false },
onSignedIn = {
signingIn = false
feed.refresh()
},
)
}
} }
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */ /** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
@Composable @Composable
private fun UsageBody( private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
state: LoadState<List<UsageSnapshot>>,
now: OffsetDateTime,
onSignIn: () -> Unit,
) {
Column { Column {
when (val current = state) { when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> is LoadState.Loaded ->
if (current.value.isEmpty()) { if (current.value.isEmpty()) {
// Not an error and not a blank screen: this provider has no paid quota, so // 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. // there is genuinely nothing to report and saying so is the answer.
Text( Text(
"This session's provider has no usage limits.", "No machine here runs anything with usage limits.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
// No card around each pool. A card is a step up the surface ladder, and inside // No card around each machine. A card is a step up the surface ladder, and
// a dialog -- itself a raised surface -- the step barely renders while costing // inside a dialog -- itself a raised surface -- the step barely renders while
// 16dp on every side. What separates one pool from the next is the line naming // costing 16dp on every side. What separates one machine from the next is the
// it. // line naming it.
current.value.forEachIndexed { index, snapshot -> current.value.forEachIndexed { index, snapshot ->
if (index > 0) { if (index > 0) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
@@ -145,18 +108,18 @@ private fun UsageBody(
// read as a section of their own. Small and quiet, because the numbers // read as a section of their own. Small and quiet, because the numbers
// below are what somebody opened this to see. // below are what somebody opened this to see.
Text( Text(
usageSectionTitle(snapshot), "${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
SnapshotState(snapshot, onSignIn) SnapshotState(snapshot)
snapshot.windows.forEachIndexed { windowIndex, window -> snapshot.windows.forEachIndexed { windowIndex, window ->
// Between the bars, not after the last one: a trailing gap here is what // Between the bars, not after the last one: a trailing gap here is what
// put a band of empty dialog above the Close button. // put a band of empty dialog above the Close button.
if (windowIndex > 0) { if (windowIndex > 0) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
WindowBar(window, now) WindowBar(window)
} }
} }
} }
@@ -164,44 +127,20 @@ private fun UsageBody(
} }
} }
private fun usageSectionTitle(snapshot: UsageSnapshot): String {
val machine = snapshot.machineName.ifEmpty { snapshot.machine }
val provider = snapshot.provider
val pool =
if (provider == "codex" && snapshot.limitId != "codex") {
when (snapshot.limitName) {
"gpt-reserve" -> "Luna Reserve"
null -> snapshot.limitId
else -> snapshot.limitName
}
} else null
return listOfNotNull(machine, provider, pool).joinToString(" · ")
}
/** /**
* Anything other than numbers: why this machine has none. * Anything other than numbers: why this machine has none.
* *
* The distinction the old single message could not draw. A machine nobody has logged in on is * 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 * 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. It still offers the direct sign-in action; * the interface nagging about a decision already made. Only the two faults are coloured as faults.
* unreachable and provider failures are the states coloured as faults.
*/ */
@Composable @Composable
private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) { private fun SnapshotState(snapshot: UsageSnapshot) {
when (snapshot.state) { when (snapshot.state) {
"ok" -> {} "ok" -> {}
"notLoggedIn", "notLoggedIn" ->
"loginRequired" -> {
Text( Text(
snapshot.detail ?: "No Claude account on this machine.", "No Claude account on this machine.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onSignIn) { Text("Sign in") }
}
"authenticating" ->
Text(
"Claude sign-in is in progress.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -223,7 +162,7 @@ private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
} }
@Composable @Composable
private fun WindowBar(window: UsageWindow, now: OffsetDateTime) { private fun WindowBar(window: UsageWindow) {
Column { Column {
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
Text( Text(
@@ -234,8 +173,12 @@ private fun WindowBar(window: UsageWindow, now: OffsetDateTime) {
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium) Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
UsageProgressIndicator(window, now, Modifier.fillMaxWidth()) LinearProgressIndicator(
resetLine(window, now)?.let { progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
color = quotaColor(window.percent),
modifier = Modifier.fillMaxWidth(),
)
resetLine(window)?.let {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
it, it,
@@ -254,8 +197,8 @@ private fun WindowBar(window: UsageWindow, now: OffsetDateTime) {
* failure appeared as an ISO string in a sentence written for a person. Both are named in * 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. * [WindowEnd], and the session bar words them the same way.
*/ */
private fun resetLine(window: UsageWindow, now: OffsetDateTime): String? = private fun resetLine(window: UsageWindow): String? =
when (val end = windowEnd(window.resetsAt, now)) { when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) {
WindowEnd.NotRunning -> null WindowEnd.NotRunning -> null
WindowEnd.Unreadable -> "reset time unreadable" WindowEnd.Unreadable -> "reset time unreadable"
is WindowEnd.Ends -> is WindowEnd.Ends ->
Binary file not shown.
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Overridden by the `bench` build type's resValue (build.gradle.kts) to "AI Sessions bench",
so the two are never mistaken for each other in the launcher or in Settings. -->
<string name="app_name">AI Sessions</string>
</resources>
@@ -1,28 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AuthenticationPromptTest {
@Test
fun an_authentication_failure_stays_actionable_through_its_terminal_status() {
val required =
authenticationPromptAfter(
false,
SessionEvent.AuthenticationRequired("sign in again"),
)
assertTrue(authenticationPromptAfter(required, SessionEvent.Status("idle")))
}
@Test
fun a_later_provider_response_makes_an_old_failure_stale() {
assertFalse(
authenticationPromptAfter(
true,
SessionEvent.AssistantText("Working again."),
)
)
}
}
@@ -1,33 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
class FilesNavigationTest {
@Test
fun `back walks through the common ancestor toward the project`() {
val project = "/home/bob/repos/project"
assertEquals("/", nextDirectoryToward("/etc", project))
assertEquals("/home", nextDirectoryToward("/", project))
assertEquals("/home/bob", nextDirectoryToward("/home", project))
assertEquals("/home/bob/repos", nextDirectoryToward("/home/bob", project))
assertEquals(project, nextDirectoryToward("/home/bob/repos", project))
assertEquals(null, nextDirectoryToward(project, project))
}
@Test
fun `back leaves a project descendant one directory at a time`() {
assertEquals(
"/home/bob/repos/project/src",
nextDirectoryToward("/home/bob/repos/project/src/main", "/home/bob/repos/project"),
)
}
@Test
fun `paths inside the machine home use tilde notation`() {
assertEquals("~", tildePath("/home/bob", "/home/bob"))
assertEquals("~/repos/project", tildePath("/home/bob/repos/project", "/home/bob/"))
assertEquals("/home/bobby/project", tildePath("/home/bobby/project", "/home/bob"))
assertEquals("/etc", tildePath("/etc", "/home/bob"))
}
}
@@ -338,21 +338,6 @@ class HighlighterTest {
assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text) assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text)
} }
@Test
fun `a diff colours changes and identifies its framing separately`() {
val code = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n context\n+new"
assertSpans(code, Language.DIFF, Kind.DELETION, "-old")
assertSpans(code, Language.DIFF, Kind.ADDITION, "+new")
assertSpans(
code,
Language.DIFF,
Kind.METADATA,
"--- a/file",
"+++ b/file",
"@@ -1 +1 @@",
)
}
@Test @Test
fun `every language the fence table knows has a scanner`() { fun `every language the fence table knows has a scanner`() {
Language.entries.forEach { spansOf("x", it) } Language.entries.forEach { spansOf("x", it) }
@@ -1,31 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class MarkdownLinksTest {
@Test
fun `absolute file paths are opened on the session machine`() {
assertEquals(
"/home/bob/repos/ai app/Main.kt",
filePathOf("/home/bob/repos/ai%20app/Main.kt"),
)
assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt"))
assertEquals("/home/bob/Main.kt", filePathOf("file://localhost/home/bob/Main.kt"))
}
@Test
fun `editor coordinates select the file itself`() {
assertEquals("/home/bob/Main.kt", filePathOf("/home/bob/Main.kt:42"))
assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt:42:7#L42"))
}
@Test
fun `ordinary links keep their external meaning`() {
assertNull(filePathOf("https://example.com/source.kt"))
assertNull(filePathOf("docs/source.kt"))
assertNull(filePathOf("//example.com/source.kt"))
assertNull(filePathOf("file://example.com/source.kt"))
}
}
@@ -1,72 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PendingMessagesTest {
private fun local(text: String = "keep this") =
QueuedMessage("local-1", text, emptyList(), local = true)
@Test
fun a_server_queue_replaces_the_local_bridge_instead_of_duplicating_it() {
val queued =
reconcileQueuedMessage(
listOf(local()),
SessionEvent.MessageQueued("server-1", "keep this", emptyList()),
)
assertEquals(1, queued.size)
assertEquals("server-1", queued.single().id)
assertTrue(!queued.single().local)
}
@Test
fun an_immediately_received_message_removes_its_local_bridge() {
val queued =
reconcileUserMessage(
listOf(local()),
SessionEvent.UserMessage("keep this", id = null, attachments = emptyList()),
)
assertTrue(queued.isEmpty())
}
@Test
fun a_transport_failure_stays_on_its_message() {
val queued = markPendingFailure(listOf(local()), "local-1", "Can't reach the server")
assertEquals("Can't reach the server", queued.single().refusal)
assertTrue(queued.single().local)
}
@Test
fun server_acceptance_keeps_the_bubble_until_the_provider_event() {
val queued = markPendingAccepted(listOf(local()), "local-1")
assertEquals(1, queued.size)
assertTrue(queued.single().serverAccepted)
}
@Test
fun discarding_a_failed_send_removes_only_that_local_copy() {
val server = QueuedMessage("server-1", "already accepted", emptyList())
val queued = listOf(local(), local("keep this one").copy(id = "local-2"), server)
val discarded = discardPendingMessage(queued, "local-1")
assertEquals(listOf("local-2", "server-1"), discarded.map { it.id })
}
@Test
fun identical_messages_are_reconciled_one_at_a_time() {
val queued = listOf(local(), local().copy(id = "local-2"))
val afterFirst =
reconcileUserMessage(
queued,
SessionEvent.UserMessage("keep this", id = null, attachments = emptyList()),
)
assertEquals(listOf("local-2"), afterFirst.map { it.id })
}
}
@@ -1,111 +0,0 @@
package com.example.aiapp
import androidx.compose.ui.geometry.Offset
import kotlin.test.Test
import kotlin.test.assertEquals
class SessionImageTest {
@Test
fun `a full-height image hides both bars`() {
assertEquals(
ViewerBars(status = true, navigation = true),
viewerBars(
imageWidth = 1000,
imageHeight = 2000,
viewportWidth = 1000,
viewportHeight = 2000,
scale = 1f,
offset = Offset.Zero,
insets = ViewerBarInsets(status = 100, navigation = 100),
),
)
}
@Test
fun `a letterboxed image leaves both bars visible`() {
assertEquals(
ViewerBars(),
viewerBars(
imageWidth = 1000,
imageHeight = 500,
viewportWidth = 1000,
viewportHeight = 2000,
scale = 1f,
offset = Offset.Zero,
insets = ViewerBarInsets(status = 100, navigation = 100),
),
)
}
@Test
fun `panning into the status bar hides only that bar`() {
assertEquals(
ViewerBars(status = true),
viewerBars(
imageWidth = 1000,
imageHeight = 500,
viewportWidth = 1000,
viewportHeight = 2000,
scale = 2f,
offset = Offset(0f, -500f),
insets = ViewerBarInsets(status = 100, navigation = 100),
),
)
}
@Test
fun `native scale reverses fitting a tall image`() {
assertEquals(
1.25f,
nativeScale(
imageWidth = 1000,
imageHeight = 2000,
viewportWidth = 1000,
viewportHeight = 1600,
),
)
}
@Test
fun `native scale leaves a small image alone`() {
assertEquals(
1f,
nativeScale(
imageWidth = 500,
imageHeight = 500,
viewportWidth = 1000,
viewportHeight = 1000,
),
)
}
@Test
fun `zoom keeps the region panned to in the center`() {
assertEquals(
Offset(240f, -160f),
zoomOffset(
offset = Offset(120f, -80f),
centroid = Offset(500f, 1000f),
pan = Offset.Zero,
oldScale = 2f,
newScale = 4f,
viewportCenter = Offset(500f, 1000f),
),
)
}
@Test
fun `zoom keeps an off-center pinch beneath moving fingers`() {
assertEquals(
Offset(460f, 420f),
zoomOffset(
offset = Offset(100f, -100f),
centroid = Offset(250f, 400f),
pan = Offset(10f, 20f),
oldScale = 2f,
newScale = 4f,
viewportCenter = Offset(500f, 1000f),
),
)
}
}
@@ -1,137 +0,0 @@
package com.example.aiapp
import java.time.OffsetDateTime
import kotlin.test.Test
import kotlin.test.assertEquals
class SessionUsageTest {
@Test
fun `usage snapshots stay with the session's machine and provider`() {
val claude = snapshot("machine", "claude", null)
val codex = snapshot("machine", "codex", "codex")
val reserve = snapshot("machine", "codex", "gpt-reserve")
val elsewhere = snapshot("other", "codex", "codex")
assertEquals(
listOf(codex, reserve),
usageSnapshotsFor(
listOf(claude, codex, reserve, elsewhere),
machine = "machine",
provider = "codex",
),
)
}
@Test
fun `a session without a meter has no usage snapshots`() {
assertEquals(
emptyList(),
usageSnapshotsFor(
listOf(snapshot("machine", "claude", null)),
machine = "machine",
provider = null,
),
)
}
@Test
fun `the model selects its named pool and other models use the generic pool`() {
val generic = snapshot("machine", "codex", "codex")
val spark =
snapshot(
"machine",
"codex",
"codex_bengalfox",
limitName = "GPT-5.3-Codex-Spark",
)
val reserve =
snapshot("machine", "codex", "base_model_inference", limitName = "gpt-reserve")
val pools = listOf(generic, spark, reserve)
assertEquals(spark, usagePoolFor(pools, "gpt-5.3-codex-spark"))
assertEquals(reserve, usagePoolFor(pools, "gpt-5.6-luna"))
assertEquals(generic, usagePoolFor(pools, "gpt-6-astra"))
}
@Test
fun `the bar uses the shortest reported cycle`() {
val weekly = window("Weekly", 10_080)
val hourly = window("5-hour window", 300)
assertEquals(hourly, shortestUsageWindow(listOf(weekly, hourly)))
assertEquals(null, shortestUsageWindow(listOf(window("unknown", null))))
}
@Test
fun `the time cursor follows elapsed time through the window`() {
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
assertEquals(
0.4f,
usageWindowElapsedFraction(
window("5-hour window", 300, "2026-09-17T15:00:00Z"),
now,
),
)
}
@Test
fun `the time cursor clamps at the window ends`() {
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
assertEquals(
0f,
usageWindowElapsedFraction(
window("5-hour window", 300, "2026-09-17T18:00:00Z"),
now,
),
)
assertEquals(
1f,
usageWindowElapsedFraction(
window("5-hour window", 300, "2026-09-17T11:00:00Z"),
now,
),
)
}
@Test
fun `the time cursor is absent without a usable duration and reset time`() {
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
assertEquals(null, usageWindowElapsedFraction(window("unknown", null), now))
assertEquals(null, usageWindowElapsedFraction(window("not running", 300), now))
assertEquals(
null,
usageWindowElapsedFraction(window("unreadable", 300, "not a timestamp"), now),
)
assertEquals(null, usageWindowElapsedFraction(window("zero", 0), now))
}
private fun snapshot(
machine: String,
provider: String,
limitId: String?,
limitName: String? = null,
) =
UsageSnapshot(
provider = provider,
machine = machine,
machineName = machine,
limitId = limitId,
limitName = limitName,
state = "ok",
detail = null,
windows = emptyList(),
)
private fun window(label: String, durationMinutes: Long?, resetsAt: String? = null) =
UsageWindow(
kind = "test",
label = label,
percent = 12.0,
durationMinutes = durationMinutes,
resetsAt = resetsAt,
active = false,
)
}
@@ -1,150 +0,0 @@
package com.example.aiapp
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The model's working as its own row, and the line under a finished reply.
*
* Both have the same shape of hazard: a state nothing measured must not come out looking like one
* that was. A block interrupted mid-thought has no duration, and a provider that reports no
* generation speed has no figure -- neither may borrow one.
*/
class ThinkingTest {
private val utc = ZoneId.of("UTC")
private var seq = 0L
private fun fold(items: List<TranscriptItem>, event: SessionEvent, ts: Double = 1.0) =
foldEvent(items, SeqEvent(seq = ++seq, ts = ts, event = event))
private fun fold(vararg events: SessionEvent) =
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
private fun thinking(items: List<TranscriptItem>) =
items.filterIsInstance<TranscriptItem.ThinkingRow>()
@Test
fun `deltas accumulate into one block that ends with its duration`() {
val items =
fold(
SessionEvent.Thinking("the user "),
SessionEvent.Thinking("wants a card"),
SessionEvent.ThinkingDone(12_400),
SessionEvent.AssistantText("Here it is."),
)
val block = thinking(items).single()
assertEquals("the user wants a card", block.text)
assertEquals(12_400, block.ms)
assertEquals("Thought for 12.4s", thinkingHeadline(block))
// Its own row, above the reply rather than inside it.
assertEquals(1, items.filterIsInstance<TranscriptItem.AssistantMsg>().size)
}
@Test
fun `a block the turn ended in the middle of stops without naming a span`() {
val items = fold(SessionEvent.Thinking("half a thought"), SessionEvent.Status("idle"))
val block = thinking(items).single()
assertNull(block.ms)
assertTrue(!block.open)
assertEquals("Thought", thinkingHeadline(block))
}
@Test
fun `a block still being thought says so`() {
val block = thinking(fold(SessionEvent.Thinking("hmm"))).single()
assertTrue(block.open)
assertEquals("Thinking", thinkingHeadline(block))
}
@Test
fun `thinking between two replies is two replies and two blocks`() {
val items =
fold(
SessionEvent.Thinking("first"),
SessionEvent.ThinkingDone(1_000),
SessionEvent.AssistantText("One."),
SessionEvent.Thinking("second"),
SessionEvent.ThinkingDone(2_000),
SessionEvent.AssistantText("Two."),
)
assertEquals(listOf("first", "second"), thinking(items).map { it.text })
assertEquals(
listOf("One.", "Two."),
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text },
)
}
@Test
fun `a reply carries when it was sent and what it cost to produce`() {
val items =
fold(emptyList(), SessionEvent.AssistantText("Done."), ts = 1_788_609_600.0).let {
fold(it, SessionEvent.UsageDelta(42, 100, 18.37, 9_489))
}
val reply = items.filterIsInstance<TranscriptItem.AssistantMsg>().single()
assertEquals(1_788_609_600.0, reply.ts)
assertEquals(18.37, reply.tokensPerSecond)
assertEquals(9_489, reply.prefillMs)
val footer = replyFooterText(reply.ts, reply.tokensPerSecond, reply.prefillMs, utc)
// The clock reading rather than the whole string: the platform's own short-time format
// differs by JDK and locale, which is the point of asking it for one.
assertTrue(footer!!.startsWith("read 9.5s · 18.4 tok/s · "), footer)
assertTrue(footer.contains("12:00"), footer)
}
@Test
fun `the clock stays at the end however much the provider measured`() {
// What a provider that measures nothing leaves: the time, and nothing in front of it.
val bare = replyFooterText(1_788_609_600.0, null, null, utc)
assertTrue(bare!!.contains("12:00"), bare)
assertTrue(!bare.contains("tok/s") && !bare.contains("read"), bare)
// Every shape ends with the same thing, which is the whole point of the order: the clock
// does not move because the session is on a provider that measures more or less.
val shapes =
listOf(
bare,
replyFooterText(1_788_609_600.0, 18.37, null, utc)!!,
replyFooterText(1_788_609_600.0, null, 9_489, utc)!!,
replyFooterText(1_788_609_600.0, 18.37, 9_489, utc)!!,
)
assertEquals(1, shapes.map { it.substringAfterLast("· ") }.distinct().size, "$shapes")
// A reply with nothing to say has no line at all rather than an empty one.
assertNull(replyFooterText(0.0, null, null, utc))
}
@Test
fun `a block cut by a page boundary is one block, and it is not still going`() {
// Each page folded on its own, as the app does: the older one holds the fragments before
// the cut and no ending, the newer one the rest and the ending.
val older = fold(SessionEvent.Thinking("half a "))
val newer = fold(SessionEvent.Thinking("thought"), SessionEvent.ThinkingDone(2_000))
val joined = joinPages(older, newer)
val block = thinking(joined).single()
assertEquals("half a thought", block.text)
assertEquals(2_000, block.ms)
assertTrue(!block.open)
}
@Test
fun `two blocks meeting at a page boundary stay two`() {
val older = fold(SessionEvent.Thinking("first"), SessionEvent.ThinkingDone(1_000))
val newer = fold(SessionEvent.Thinking("second"), SessionEvent.ThinkingDone(2_000))
assertEquals(listOf("first", "second"), thinking(joinPages(older, newer)).map { it.text })
}
@Test
fun `usage that lands after a tool call is not folded onto an older reply`() {
val items =
fold(
SessionEvent.AssistantText("Reading it."),
SessionEvent.ToolStart("t1", "Read", "{}"),
SessionEvent.ToolEnd("t1", "done"),
SessionEvent.UsageDelta(42, 100, 18.0, 500),
)
assertNull(items.filterIsInstance<TranscriptItem.AssistantMsg>().single().tokensPerSecond)
}
}
@@ -1,43 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ToolInputTest {
@Test
fun `bash wrapper ignores double quotes inside its outer pair`() {
assertEquals(
"rg -n \"needle\" server app",
renderedBashScript("/usr/bin/bash -lc \"rg -n \"needle\" server app\""),
)
}
@Test
fun `bash wrapper ignores single quotes inside its outer pair`() {
assertEquals(
"printf 'hello'",
renderedBashScript("/bin/bash -lc 'printf 'hello''"),
)
}
@Test
fun `unquoted or unfamiliar commands stay intact`() {
assertNull(renderedBashScript("/usr/bin/bash -lc echo hello"))
assertNull(renderedBashScript("/usr/bin/fish -lc 'echo hello'"))
}
@Test
fun `missing optional input is not displayed as null`() {
assertTrue(parseToolInput("TaskOutput", "null").rest.isEmpty())
}
@Test
fun `collaboration calls say what they do and omit empty completion`() {
assertEquals("Spawn agent", toolDisplayName("Task"))
assertEquals("Wait for agents", toolDisplayName("TaskOutput"))
assertEquals("", toolDisplayOutput("TaskOutput", "completed"))
assertEquals("failed", toolDisplayOutput("TaskOutput", "failed"))
}
}
@@ -1,167 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* How a run of tool calls is cut into rows: the call still running, the last call in the
* transcript, and one held out because the reader has it open are drawn on their own, and every
* piece the cut leaves behind still has a key of its own -- two rows sharing one key take the app
* down, and a key that moves takes the reader's place with it.
*/
class ToolRowsTest {
private var seq = 0L
private fun call(id: String, runId: String = id, done: Boolean = true) =
TranscriptItem.ToolRun(
seq = ++seq,
id = id,
runId = runId,
tool = "Bash",
input = "{}",
output = if (done) "ok" else "",
done = done,
)
/** Something that is not a tool call, to put behind the run so its last call folds in. */
private fun reply() = TranscriptItem.AssistantMsg(seq = ++seq, text = "done")
private fun shape(rows: List<TranscriptRow>) = rows.map { row ->
when (row) {
is TranscriptRow.Tools -> row.calls.map { it.id }
is TranscriptRow.Single -> listOf((row.item as? TranscriptItem.ToolRun)?.id ?: "reply")
}
}
private fun assertKeysDistinct(rows: List<TranscriptRow>) =
assertEquals(rows.size, rows.map { it.key }.toSet().size, "$rows")
@Test
fun the_call_still_running_is_a_row_of_its_own() {
val rows =
groupToolRuns(
listOf(
call("a"),
call("b", runId = "a"),
call("c", runId = "a", done = false),
call("d", runId = "a"),
reply(),
)
)
assertEquals(
listOf(listOf("a", "b"), listOf("c"), listOf("d"), listOf("reply")),
shape(rows),
)
assertKeysDistinct(rows)
}
@Test
fun a_call_running_in_the_middle_of_its_run_splits_the_group_in_two() {
val rows =
groupToolRuns(
listOf(
call("a"),
call("b", runId = "a", done = false),
call("c", runId = "a"),
call("d", runId = "a"),
reply(),
)
)
assertEquals(
listOf(listOf("a"), listOf("b"), listOf("c", "d"), listOf("reply")),
shape(rows),
)
assertKeysDistinct(rows)
}
/**
* A call held out is one the reader opened while it stood on its own; being overtaken while
* they read it does not fold it away, and closing it hands it back to its run.
*/
@Test
fun a_held_out_call_stays_out_of_its_group() {
val calls =
listOf(
call("a"),
call("b", runId = "a"),
call("c", runId = "a"),
call("d", runId = "a"),
reply(),
)
val whileHeld = groupToolRuns(calls, heldOut = setOf("d"))
val afterItCloses = groupToolRuns(calls)
assertEquals(listOf(listOf("a", "b", "c"), listOf("d"), listOf("reply")), shape(whileHeld))
assertTrue(whileHeld[1] is TranscriptRow.Single, "$whileHeld")
assertKeysDistinct(whileHeld)
assertEquals(listOf(listOf("a", "b", "c", "d"), listOf("reply")), shape(afterItCloses))
assertTrue(afterItCloses.first() is TranscriptRow.Tools, "$afterItCloses")
}
/**
* The one case where the run's name is a call that is not in the run's first row: a page of
* history joined onto a run whose own first call is still going ([joinPages] renames the older
* calls to the newer run's name). Both rows would key on that name.
*/
@Test
fun the_run_keeps_its_name_even_when_the_call_it_is_named_after_is_the_one_running() {
val rows = groupToolRuns(listOf(call("a", runId = "b"), call("b", done = false)))
assertEquals(listOf(listOf("a"), listOf("b")), shape(rows))
assertKeysDistinct(rows)
assertEquals("b", rows.first().key)
}
@Test
fun a_run_that_reappears_after_another_row_keeps_distinct_keys() {
val rows =
groupToolRuns(
listOf(
call("older", runId = "exec-1"),
call("older-2", runId = "exec-1"),
reply(),
call("exec-1", runId = "exec-1"),
call("newer", runId = "exec-1"),
reply(),
)
)
assertTrue(rows[0] is TranscriptRow.Tools, "$rows")
assertTrue(rows[2] is TranscriptRow.Tools, "$rows")
assertKeysDistinct(rows)
assertEquals("exec-1", rows[0].key)
assertEquals("exec-1/exec-1", rows[2].key)
}
/**
* Finishing is not what folds a call back in -- being overtaken is. A session that has run its
* last command and is writing its reply leaves that command standing until the reply starts.
*/
@Test
fun the_last_call_stays_out_when_it_finishes_and_folds_in_when_something_follows() {
val a = call("a")
val running = call("b", runId = "a", done = false)
val finished = running.copy(done = true)
val whileRunning = groupToolRuns(listOf(a, running))
val afterItEnds = groupToolRuns(listOf(a, finished))
val afterTheReply = groupToolRuns(listOf(a, finished, reply()))
assertEquals(listOf(listOf("a"), listOf("b")), shape(whileRunning))
assertEquals(listOf(listOf("a"), listOf("b")), shape(afterItEnds))
assertEquals(listOf(listOf("a", "b"), listOf("reply")), shape(afterTheReply))
// The run keeps the key it was drawn under throughout, so the list rebuilds a row rather
// than losing its anchor.
assertEquals(whileRunning.first().key, afterItEnds.first().key)
assertEquals(whileRunning.first().key, afterTheReply.first().key)
}
@Test
fun a_run_of_finished_calls_is_one_group_once_something_follows_it() {
val rows =
groupToolRuns(
listOf(call("a"), call("b", runId = "a"), call("c", runId = "a"), reply())
)
assertEquals(listOf(listOf("a", "b", "c"), listOf("reply")), shape(rows))
assertTrue(rows.first() is TranscriptRow.Tools, "$rows")
}
}
@@ -1,185 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Where one turn ends and the next begins, which is the part of the fold that had no way of saying
* anything was wrong: two replies run together read as one long answer, and the seam is somewhere
* in the middle of a sentence.
*/
class TranscriptItemsTest {
private var seq = 0L
private fun fold(items: List<TranscriptItem>, event: SessionEvent) =
foldEvent(items, SeqEvent(seq = ++seq, ts = 1.0, event = event))
private fun fold(vararg events: SessionEvent) =
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
private fun texts(items: List<TranscriptItem>) =
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text }
@Test
fun an_authentication_failure_stays_visible_as_an_error_row() {
val entry =
SeqEvent(
seq = 7,
ts = 1.0,
event = SessionEvent.AuthenticationRequired("sign in again"),
)
assertEquals(
TranscriptItem.ErrorMsg(7, "sign in again"),
foldEvent(emptyList(), entry).single(),
)
}
@Test
fun text_after_the_turn_ended_is_a_new_reply_rather_than_more_of_the_last_one() {
val items =
fold(
SessionEvent.AssistantText("You'll get the one-line notice when it lands."),
SessionEvent.Status("idle"),
SessionEvent.AssistantText("Dev Updater fix is pushed."),
)
assertEquals(
listOf("You'll get the one-line notice when it lands.", "Dev Updater fix is pushed."),
texts(items),
)
}
@Test
fun deltas_of_one_reply_still_accumulate_into_it() {
val items =
fold(
SessionEvent.AssistantText("Still "),
SessionEvent.AssistantText("running "),
SessionEvent.Status("running"),
SessionEvent.AssistantText("its tests."),
)
assertEquals(listOf("Still running its tests."), texts(items))
}
@Test
fun completed_text_replaces_provisional_deltas_live() {
val items =
fold(
SessionEvent.AssistantText("I'll inspect the color-c concrete implementation"),
SessionEvent.AssistantTextFinal(
"Ill inspect the color-correction TODO and the relevant design."
),
)
assertEquals(
listOf("Ill inspect the color-correction TODO and the relevant design."),
texts(items),
)
}
@Test
fun completed_text_discards_provisional_deltas_across_a_page_boundary() {
val earlier = fold(SessionEvent.AssistantText("1. provisional section\n\n"))
val later =
fold(
SessionEvent.AssistantTextFinal("1. final first section\n\n2. final second section")
)
assertEquals(
listOf("1. final first section\n\n2. final second section"),
texts(joinPages(earlier, later)),
)
}
@Test
fun a_final_value_after_a_settled_reply_is_a_new_message_across_a_page_boundary() {
val earlier =
fold(
SessionEvent.AssistantText("Previous answer."),
SessionEvent.Status("idle"),
)
val later = fold(SessionEvent.AssistantTextFinal("Next answer."))
assertEquals(
listOf("Previous answer.", "Next answer."),
texts(joinPages(earlier, later)),
)
}
/**
* The rule that replaced the wall of reports. A turn that starts with nothing recorded in front
* of it -- a subagent finishing, the CLI picking a conversation back up -- leaves two replies
* abutting, and only the break says they are two.
*/
@Test
fun two_replies_that_meet_are_separated_by_a_rule_and_nothing_else() {
val items =
fold(
SessionEvent.AssistantText("Launched it."),
SessionEvent.Status("waiting"),
SessionEvent.AssistantText("Noted."),
)
assertEquals(3, items.size, "$items")
assertTrue(items[1] is TranscriptItem.TurnBreak, "$items")
assertEquals(listOf("Launched it.", "Noted."), texts(items))
// Distinct keys: the break shares the reply's seq, and two items with one key take the
// app down.
assertEquals(3, items.map { it.key }.toSet().size, "$items")
}
/**
* A reply after anything that draws a row of its own needs no rule: that row is the boundary.
*/
@Test
fun a_reply_after_a_row_of_its_own_gets_no_rule() {
val items =
fold(
SessionEvent.AssistantText("Launched it."),
SessionEvent.Status("idle"),
SessionEvent.UserMessage("carry on", null, emptyList()),
SessionEvent.AssistantText("Noted."),
)
assertTrue(items.none { it is TranscriptItem.TurnBreak }, "$items")
}
@Test
fun a_repeated_tool_start_is_still_one_row() {
val start = SessionEvent.ToolStart("exec-1", "Bash", "{\"command\":\"cargo test\"}")
val items =
fold(
start,
SessionEvent.AssistantText("The test run is still going."),
start,
SessionEvent.ToolEnd("exec-1", "finished"),
)
val tools = items.filterIsInstance<TranscriptItem.ToolRun>()
assertEquals(1, tools.size, "$items")
assertEquals("finished", tools.single().output)
assertTrue(tools.single().done)
}
/**
* The page-join half of the same rule. A boundary that cuts one reply leaves an unfinished half
* to be rejoined; a boundary that lands between two turns must not join anything, or paging
* back puts the run-together paragraph straight back.
*/
@Test
fun paging_back_rejoins_a_cut_reply_and_leaves_two_finished_ones_apart() {
val cut =
joinPages(
listOf(TranscriptItem.AssistantMsg(1, "half a ")),
listOf(TranscriptItem.AssistantMsg(2, "sentence", settled = true)),
)
assertEquals(listOf("half a sentence"), texts(cut))
val whole =
joinPages(
listOf(TranscriptItem.AssistantMsg(1, "One turn.", settled = true)),
listOf(TranscriptItem.AssistantMsg(2, "The next.", settled = true)),
)
assertEquals(listOf("One turn.", "The next."), texts(whole))
// And the rule between them, which the fold that would have made it never got to see.
assertTrue(whole.any { it is TranscriptItem.TurnBreak }, "$whole")
}
}
+28
View File
@@ -0,0 +1,28 @@
# The P0 benchmark fixture
`transcript.jsonl` is a synthetic transcript in the app's own event model (the JSON lines
`GET /sessions/{id}/transcript` returns; see `Events.kt`'s `parseSeqEvent` and
`server/src/session/driver.rs`) -- never a real one. It is what both the Compose `bench` build
and iris's bench build open with no server, so the two apps draw exactly the same content and a
frame-time comparison is measuring the renderer rather than the data.
Generated by `./generate.py` (Python stdlib only, seeded -- `SEED = 20260905` -- so re-running it
reproduces the same file byte for byte). It writes into `assets/` -- a separate directory from this
script and README, because the Compose `bench` build type points its own asset source set straight
at `assets/` (`app/androidApp/build.gradle.kts`'s `sourceSets { getByName("bench") }`), and a Python
script and a markdown file have no business inside an APK:
- `transcript.jsonl` -- 3,601 events. The first 3,200 (`BACKLOG_COUNT`) are the scrolled-back
history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output,
assistant replies built from headings, bold/italic/inline code, a link, fenced code blocks that
rotate through rust/kotlin/python/sh/json/toml, a markdown table, two embedded images, and
periodic `usageDelta`/`compacted` events. The remaining 400 (`STREAM_COUNT`) are not part of the
opening window -- both bench harnesses replay them at a fixed rate (20/s) through the same live
fold path a real SSE reply arrives on, which is P0's "streaming phase."
- `bench1.png`, `bench2.png` -- tiny (8x8) flat-colour PNGs, base64-free on disk but served the
same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two
`"type":"image"` events in the transcript.
Regenerate after changing the shape (a new event type, a different backlog/stream split) with
`./generate.py`, and commit the result -- it is checked in rather than generated at build time so
both apps' bench builds embed the identical bytes without needing this script at build time.
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 B

File diff suppressed because it is too large. Load diff
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Generates transcript.jsonl -- the synthetic fixture P0's benchmark opens in both apps.
Deterministic (fixed seed), so a Compose bench APK and an iris bench APK draw byte-identical
content: the point of the fixture is a like-for-like comparison, not a realistic one.
Never a real transcript -- see AGENTS.md's ui-sandbox.sh, which this borrows its vocabulary
style from (headings, code fences, a table, a link) rather than reusing its Claude-Code JSONL
shape. This file's shape is the *app's own event model* instead: one JSON object per line,
matching what GET /sessions/{id}/transcript returns and what Events.kt's parseSeqEvent reads
(server/src/session/driver.rs is the source of truth for the field names).
./generate.py writes transcript.jsonl and bench1.png/bench2.png here
BACKLOG_COUNT events (seq 1..BACKLOG_COUNT) are the scrolled-back history the benchmark opens
with. A further STREAM_COUNT events (seq BACKLOG_COUNT+1..) are not part of the opening window;
both bench harnesses replay them at a fixed rate as the "streaming reply" phase, appended through
the same live path a real SSE reply arrives on. Keeping both halves in one file means one
generator and one seed to keep in sync, rather than two fixtures that can drift apart.
"""
import base64
import json
import random
import struct
import zlib
from pathlib import Path
SEED = 20260905
BACKLOG_COUNT = 3200
STREAM_COUNT = 400
HERE = Path(__file__).resolve().parent / "assets"
random.seed(SEED)
LANGUAGES = ["rust", "kotlin", "python", "sh", "json", "toml"]
CODE_SNIPPETS = {
"rust": '''fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
// a comment worth keeping: this is the fold the app's own screen runs
let mut out = items;
out.push(Item::new(seq));
out
}''',
"kotlin": '''fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> {
// mirrors the server's own event model, one item per line
return items + TranscriptItem.from(entry)
}''',
"python": '''def render_report(frames, cpu_ms, rss_kb):
# printed for a human to paste back, so every number carries its unit
return f"{frames} frames, {cpu_ms}ms cpu, {rss_kb}kb peak rss"''',
"sh": '''#!/bin/sh
# scripted scroll loop, the shape transcript-bench.sh drives on a phone
for i in $(seq 1 24); do
ui-trace record --do "swipe 540 700 540 1600 200"
done''',
"json": '{"seq": 1, "type": "status", "state": "running"}',
"toml": '''[package]
name = "bench-fixture"
version = "0.1.0"''',
}
HEADINGS = [
"## Plan",
"## What changed",
"## Why this approach",
"### Open questions",
"## Results",
]
WORDS = (
"session render report frame budget scroll transcript fold event cache "
"cursor probe stream backlog swipe fixture bench compose iris widget layout "
"measure place draw tool call token context window anchor"
).split()
def paragraph(n=24):
words = [random.choice(WORDS) for _ in range(n)]
words[0] = words[0].capitalize()
text = " ".join(words) + "."
# Sprinkle markdown inline spans so the syntax highlighter/markdown parser sees a real mix.
text = text.replace(" fold ", " **fold** ", 1)
text = text.replace(" cursor ", " *cursor* ", 1)
text = text.replace(" cache ", " `cache` ", 1)
if "bench" in text:
text = text.replace(
" bench ", " [bench](https://example.com/bench) ", 1
)
return text
def make_png(rgb, size=8):
"""A tiny, valid PNG -- flat colour, no external dependency."""
def chunk(tag, data):
c = tag + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c))
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
raw = b""
for _ in range(size):
raw += b"\x00" + bytes(rgb) * size
idat = zlib.compress(raw)
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
def main():
HERE.mkdir(exist_ok=True)
lines = []
seq = 1
ts = 1_788_000_000.0
def emit(type_, **fields):
nonlocal seq, ts
obj = {"seq": seq, "ts": round(ts, 3), "type": type_}
obj.update(fields)
lines.append(json.dumps(obj, separators=(",", ":")))
seq += 1
ts += random.uniform(0.05, 2.0)
emit("status", state="running")
emit("settings", model="bench-model", permissionMode="auto")
image_refs = []
turn = 0
while seq <= BACKLOG_COUNT:
turn += 1
emit("userMessage", text=f"Turn {turn}: {paragraph(12)}", id=None, attachments=[])
# A tool call with kilobyte-scale input/output every few turns.
if turn % 3 == 0:
tool_id = f"tool-{turn}"
big_input = json.dumps({"path": f"/repo/file_{turn}.rs", "content": paragraph(400)})
emit("toolStart", id=tool_id, tool="Edit", input=big_input)
big_output = "\n".join(paragraph(60) for _ in range(20))
emit("toolUpdate", id=tool_id, output=big_output[: len(big_output) // 2])
emit("toolEnd", id=tool_id, output=big_output)
# A reply: a heading, prose, a fenced block in a rotating language, a table, then deltas.
emit("assistantText", delta=f"{random.choice(HEADINGS)}\n\n")
emit("assistantText", delta=paragraph(30) + "\n\n")
lang = LANGUAGES[turn % len(LANGUAGES)]
emit("assistantText", delta=f"```{lang}\n{CODE_SNIPPETS[lang]}\n```\n\n")
if turn % 5 == 0:
emit(
"assistantText",
delta="| column | value |\n|---|---|\n| a | " + paragraph(3) + " |\n\n",
)
# A run of small deltas -- the shape a live reply actually streams in.
for _ in range(random.randint(3, 8)):
emit("assistantText", delta=paragraph(6) + " ")
# A couple of images, base64 PNGs, the way a real transcript embeds a screenshot.
if turn in (10, 40):
ref = f"bench{len(image_refs) + 1}.png"
image_refs.append(ref)
emit("image", ref=ref, about=None)
emit("usageDelta", tokens=random.randint(200, 4000), context=random.randint(2000, 180000))
if turn % 15 == 0:
emit(
"compacted",
preTokens=180000,
postTokens=20000,
trigger="auto",
)
# The streaming-phase tail: one long reply, built entirely from text deltas, the shape a
# bench harness replays at a fixed events/sec through the live fold path.
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
while seq <= BACKLOG_COUNT + STREAM_COUNT:
emit("assistantText", delta=paragraph(5) + " ")
emit("status", state="idle")
(HERE / "transcript.jsonl").write_text("\n".join(lines) + "\n")
(HERE / "bench1.png").write_bytes(make_png((220, 90, 90)))
(HERE / "bench2.png").write_bytes(make_png((90, 150, 220)))
print(f"wrote {len(lines)} events ({BACKLOG_COUNT} backlog + {STREAM_COUNT} stream) to transcript.jsonl")
if __name__ == "__main__":
main()
+9 -3
View File
@@ -4,6 +4,11 @@
# ./build-apk.sh the release build, signed (what the phone runs) # ./build-apk.sh the release build, signed (what the phone runs)
# ./build-apk.sh debug the debug build, for reproducing something the # ./build-apk.sh debug the debug build, for reproducing something the
# emulator scripts would build anyway # emulator scripts would build anyway
# ./build-apk.sh bench P0's benchmark build (own app id, "AI Sessions
# bench" label, opens straight onto the fixture
# session -- see docs/RUST.md's P0 box and
# app/bench-fixture/README.md). Signed the same
# as release; never touches the CA it pins.
# #
# Dev Updater's `.dev-updater.ron` at the checkout root spells these out as # Dev Updater's `.dev-updater.ron` at the checkout root spells these out as
# build modes, one command line each; it passes nothing else, so the word # build modes, one command line each; it passes nothing else, so the word
@@ -25,8 +30,9 @@ VARIANT=${1:-release}
case "$VARIANT" in case "$VARIANT" in
release) TASK=assembleRelease ;; release) TASK=assembleRelease ;;
debug) TASK=assembleDebug ;; debug) TASK=assembleDebug ;;
bench) TASK=assembleBench ;;
*) *)
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug)" >&2 echo "build-apk.sh: unknown variant '$VARIANT' (release, debug, bench)" >&2
exit 2 exit 2
;; ;;
esac esac
@@ -81,7 +87,7 @@ fi
# uninstalling it first: the signatures differ, and Android refuses to # uninstalling it first: the signatures differ, and Android refuses to
# update across them. # update across them.
KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}" KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}"
if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then if { [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; } && [ ! -f "$KEYSTORE" ]; then
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}" KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}"
KEYTOOL="${KEYTOOL:-keytool}" KEYTOOL="${KEYTOOL:-keytool}"
if ! command -v "$KEYTOOL" >/dev/null 2>&1; then if ! command -v "$KEYTOOL" >/dev/null 2>&1; then
@@ -97,7 +103,7 @@ if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
-keyalg RSA -keysize 2048 -validity 10000 \ -keyalg RSA -keysize 2048 -validity 10000 \
-storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1) -storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1)
fi fi
if [ "$VARIANT" = release ]; then if [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; then
AI_APP_KEYSTORE="$KEYSTORE" AI_APP_KEYSTORE="$KEYSTORE"
AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password") AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password")
export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD
-1
View File
@@ -45,7 +45,6 @@ GLYPHS=(
U+F0193 # md-content_save U+F0193 # md-content_save
U+F0224 # md-file_outline U+F0224 # md-file_outline
U+F201 # fa-line_chart -- Font Awesome's, asked for by name U+F201 # fa-line_chart -- Font Awesome's, asked for by name
U+F035C # md-menu -- the burger, as a row's drag handle
) )
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
+2 -2
View File
@@ -120,10 +120,10 @@ TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; } api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
echo "==> Importing" echo "==> Importing"
MACHINE=$(api "https://127.0.0.1:$PORT/machines" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) SETUP=$(api "https://127.0.0.1:$PORT/setups" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
SESSION=$(api -H 'Content-Type: application/json' -X POST \ SESSION=$(api -H 'Content-Type: application/json' -X POST \
"https://127.0.0.1:$PORT/sessions" \ "https://127.0.0.1:$PORT/sessions" \
-d "{\"machine\":\"$MACHINE\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \ -d "{\"setup\":\"$SETUP\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events" echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# RUST.md's I5 "Where iris's frame time goes" pass (2026-09-05). The same
# 24-swipe/6-cycle loop as transcript-bench.sh's, extracted for iris's own
# demo app -- transcript-bench.sh itself is Compose-specific (opens by
# session title through the Compose app's own UI) and cannot be called
# directly against dev.iris.android.demo.
#
# MUST be run from inside this checkout (not /tmp): ui-trace/adb pick which
# emulator to target from the current directory's basename (the
# per-checkout-AVD rule), and a previous pass lost two attempts to a `cd`
# into /tmp that made this resolve to a nonexistent "tmp" checkout.
set -eu
cd "$(dirname "$0")"
. ./android-env.sh >/dev/null 2>&1
cycles=${1:-6}
ui-trace record -d 3000 --do "tap 'Reset frame report'" -o /tmp/iris-bench-reset.txt >/dev/null
adb logcat -c
DO=""
i=0
while [ "$i" -lt "$cycles" ]; do
DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'"
DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'"
DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'"
DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'"
i=$((i + 1))
done
eval ui-trace record -d $((cycles * 16000 + 20000)) $DO -o /tmp/iris-bench-scroll.txt >/dev/null
ui-trace record -d 3000 --do "tap 'Frame report'" -o /tmp/iris-bench-report.txt >/dev/null
sleep 1
adb logcat -d -s iris-android-app:I | grep "iris frame report:"
+1 -1
View File
@@ -9,7 +9,7 @@
# emulator" is three places for the memory check that was missing from all of # emulator" is three places for the memory check that was missing from all of
# them. # them.
# #
# Environment machine (SDK location, PATH, ...) lives in ./android-env.sh, # Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
# which can also be sourced directly for one-off commands. # which can also be sourced directly for one-off commands.
set -eu set -eu
+5
View File
@@ -17,6 +17,11 @@ dependencyResolutionManagement {
include(":androidApp") 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 // 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 // this checkout and the crate it consumes move together -- the same
// arrangement `server/` uses for the Rust half. See that repo's README. // 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>
@@ -0,0 +1,50 @@
package com.example.aiapp.shell;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.widget.Toast;
/**
* E3's floor, per RUST.md's "How much Java is unavoidable": a class the framework
* constructs by name from the manifest, with its lifecycle methods handing straight to Rust
* (android-shell's {@code share::handle_intent}). No Compose, no layout -- there is no screen to
* draw yet (that is E4's job, on iris); {@link #toast} is this experiment's stand-in for showing
* something happened.
*/
public class MainActivity extends Activity {
static {
System.loadLibrary("android_shell");
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
NotificationService.sync(this);
nativeHandleIntent(this, getIntent());
}
// launchMode="singleTop": a notification tap or a share while this activity is already on
// top lands here rather than in a second instance -- same reasoning as MainActivity.kt's.
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
nativeHandleIntent(this, intent);
}
/**
* Called from android-shell, sometimes from a background thread (a share's network call is
* never made on the calling thread -- see share.rs). {@code Toast} itself is main-thread-only,
* so this hops there with a {@link Handler} rather than assuming the caller already has.
*/
static void toast(Context context, String message) {
new Handler(Looper.getMainLooper())
.post(() -> Toast.makeText(context, message, Toast.LENGTH_LONG).show());
}
private static native void nativeHandleIntent(Activity activity, Intent intent);
}
@@ -0,0 +1,45 @@
package com.example.aiapp.shell;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.IBinder;
/**
* E3's second unavoidable Java class (RUST.md): a foreground service constructed by the framework
* from the manifest, existing only to hand its lifecycle to android-shell's {@code notify} module
* -- the SSE follow loop, deciding what a notification says, and posting it are all Rust reached
* through these three native calls. See {@code Notifications.kt}'s {@code NotificationService} for
* the Kotlin original this mirrors.
*/
public class NotificationService extends Service {
static {
System.loadLibrary("android_shell");
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
return nativeOnStartCommand(this);
}
@Override
public void onDestroy() {
nativeOnDestroy();
}
/** Starts this service if there is a server to connect to, and stops it otherwise. */
static void sync(Context context) {
nativeSync(context);
}
private static native void nativeSync(Context context);
private static native int nativeOnStartCommand(Service service);
private static native void nativeOnDestroy();
}
+6 -17
View File
@@ -110,7 +110,7 @@ api) # ./ui-sandbox.sh api /path [curl args...]
;; ;;
spawn) # ./ui-sandbox.sh spawn [title] -- an echo session; prints its id spawn) # ./ui-sandbox.sh spawn [title] -- an echo session; prints its id
api /sessions -X POST -H 'content-type: application/json' \ api /sessions -X POST -H 'content-type: application/json' \
-d "{\"machine\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" | -d "{\"setup\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'
exit 0 exit 0
;; ;;
@@ -150,7 +150,7 @@ if [ -f "$ROOT/config.ron" ]; then
/^tokens: \[/ { in_tokens = 1; next } /^tokens: \[/ { in_tokens = 1; next }
# The server writes the list back compactly, with the last entry # The server writes the list back compactly, with the last entry
# and the close on one line: " ),],". Reading the close only # and the close on one line: " ),],". Reading the close only
# at a line start ran past it into `machines`, and the salvage then # at a line start ran past it into `setups`, and the salvage then
# carried a second copy of that block into the new config. # carried a second copy of that block into the new config.
in_tokens && /\],/ { in_tokens && /\],/ {
sub(/\],.*/, "") sub(/\],.*/, "")
@@ -213,24 +213,13 @@ while [ "$i" -le 8 ]; do
i=$((i + 1)) i=$((i + 1))
done done
# A CLI that does nothing during a session and offers one deterministic login # A CLI that does nothing, so importing one of these is free and safe.
# during `auth login`, so both paths are free and safe. Everything the spawn # Everything the spawn path cares about is here: it holds the fifo open,
# path cares about is here: it holds the fifo open, records a real pid, writes # records a real pid, writes nothing, and dies on a signal. A real
# nothing, and dies on a signal. A real
# `claude --resume` against an invented session id would either fail in a # `claude --resume` against an invented session id would either fail in a
# way that tests nothing or start a turn on somebody's account. # way that tests nothing or start a turn on somebody's account.
cat >"$ROOT/fake-claude" <<FAKE cat >"$ROOT/fake-claude" <<FAKE
#!/bin/sh #!/bin/sh
if [ "\${1:-}" = auth ] && [ "\${2:-}" = login ]; then
echo 'https://claude.com/cai/oauth/authorize?state=ai-app-sandbox'
while IFS= read -r code; do
if [ "\$code" = sandbox-code ]; then
exit 0
fi
echo 'Invalid code' >&2
done
exit 1
fi
# Slow to start, on purpose. An import against this finishes in # Slow to start, on purpose. An import against this finishes in
# milliseconds otherwise, so every state on the way -- the row marked # milliseconds otherwise, so every state on the way -- the row marked
# "importing", the queue behind it, the event that clears them -- is over # "importing", the queue behind it, the event that clears them -- is over
@@ -357,7 +346,7 @@ tokens: [
sha256: "$hash", sha256: "$hash",
), ),
$salvaged], $salvaged],
machines: [ setups: [
( (
id: "local", id: "local",
name: "sandbox", name: "sandbox",
+940
View File
@@ -0,0 +1,940 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"serde",
"serde_json",
"tempfile",
"ureq",
]
[[package]]
name = "cookie"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "crc32fast"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
dependencies = [
"cfg-if",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "event-model"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "smallvec"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
dependencies = [
"base64",
"cookie_store",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff. Show more