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 cf10b17c5b End a subagent on its own end_turn, not the parent's tool_result, and give the expander a touch-sized row
The Agent tool runs subagents in the background, so the parent's result
arrives at launch while the subagent works on for minutes; finishing on it
read a running agent as finished with a transcript cut off at launch. A
subagent now ends on its own message_delta end_turn, and a later line for a
finished one reopens it, since a background agent can be messaged again.

The card's expander row was only the chevron's height, so a tap for it
landed on the first subcard; it is the platform's 48dp minimum now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:44:00 -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 9fa09b0af1 Show a session's subagents as subcards, each with a read-only transcript
A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.

On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.

Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:41:15 -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 eff5c8b0c0 Let the machine's own CLI refresh an expired token, and retry once
A 401 from the usage endpoint means the stored access token has expired.
Refreshing it here is not an option: Anthropic's OAuth rotates the refresh
token, so a second refresher invalidates the CLI's copy and forces a
re-login on a machine that usually has a live session on it. So run the CLI
there instead and re-read what it wrote.

`doctor` rather than `auth status`: probed against 2.1.258 with an invalid
token, `auth status` answers loggedIn:true from the file alone and never
reaches the network. The same probe showed a failed refresh blanks both
tokens, which is why this stays on the 401 path.

Also gives ProviderConfig one program() so the CLI's default path is not
written down twice.
2026-09-05 12:07:34 -04:00
iris 7b63330aaa Say when a usage 401 is an expired login, not an unreachable endpoint
A 401 is the endpoint answering and refusing the stored OAuth token, which
Claude Code refreshes as it runs -- so a machine whose CLI has been idle
hands us a stale one. Reporting it as "usage endpoint unreachable" pointed
at the network instead of at the one thing that fixes it.
2026-09-05 11:58:17 -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 Opus 5 6bdec6e785 Let a session resume itself when its usage limit lifts
Off by default and per session: it spends quota the moment quota exists,
with nobody watching, which is not a thing a default may decide. Switched
on from the session settings dialog, with the message it sends editable
("continue" unless something else is typed).

Running out of quota becomes a state rather than an error. The Claude
driver recognises its dialect's sentence -- `Claude AI usage limit
reached|1788546972` -- and reports `LimitReached` with the reset time it
gave; nothing above a driver matches on a string. The transcript draws it
as a divider, like a clear or a compaction.

The schedule is a plan to *ask*, never a plan to send. Both reset times
available are untrustworthy in the direction that matters -- the dialect's
is written when the turn fails, the endpoint's moves when the window does
-- so the wait ends in a question to the usage meter, and only `ok` with
no window at 100% sends anything. A window still spent reschedules to its
own reset time, which is what makes a limit that lifts late wait longer
and one that lifts early resume sooner. A meter that cannot be asked is a
longer wait too, never a send. A day after the limit was hit the wait
gives up and says so in the transcript, so a machine that can never be
asked is not retried for ever.

The schedule is persisted on the session: a five-hour window outlasts a
backend restart, and a wait forgotten across one never comes back.

Driven end to end with echo, never a real account: `/limit [minutes]`
reports the same event a real driver does and `/usage` sets what the meter
answers, deliberately separate so the two can disagree. The wait moved
from the dialect's two minutes to the meter's seven when the meter changed
its mind, and the message went out on the first check after the meter came
back under the limit.

Also makes the settings dialog scrollable, which these two controls made
necessary: at a 1.5x system font it clipped the last of them with nothing
on screen to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 05:21:43 -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 Opus 5 4821a02bd3 Default thinking level for new sessions, and move the rigs out of AGENTS.md
`Config::default_effort` is what a session starts at when nothing chose one,
applied in `spawn_session` rather than filled in by the spawn screen so it
holds for an import and a bare API call too. It is set by the spawn screen's
own picker, whose label says so: one control, where new sessions are made,
rather than a settings page for a single value. Not on a provider, because
providers are discovered and the next rediscovery would erase it; not on the
phone, because a second device would then spawn at a level nobody there
chose. `GET`/`POST /defaults` carry it as a struct, so the permission mode --
still hardcoded to `auto` on the spawn screen -- can move there later without
a second route.

Only drivers that read a level are given one: an echo session was storing a
`--effort` it never passes to anything, which is a config file answering a
question about itself wrongly.

Separately, `AGENTS.md` is 35 KB sent with every request in this repo, and 12
KB of it was rigs and reference measurements that only matter once you are
running one. Those are the `ai-app-rigs` skill now -- the same text, still the
only copy, read when the work touches it. 35,198 -> 20,813 chars.

Verified on the emulator against the sandbox: the spawn screen pre-fills from
the server, picking `low` spawned a session at `low` and left `/defaults` set
to it, and an echo session spawned afterwards took no level at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:42:05 -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 Opus 5 1ff662c7c3 Let a session choose how hard it thinks
Output is about an eighth of what a session costs and thinking is nearly
all of it -- prose is ~1.5% of output tokens, measured over 27,015 requests
of this account's own transcripts -- so the level is the largest saving
available short of shortening the conversation itself.

Shaped like the working directory rather than like the model: the CLI's
only two setting control requests are `set_model` and `set_permission_mode`
(checked against the 2.1.258 binary), so `--effort` is read when the process
launches and cannot be asked of a running one. `set_session_effort` records
the level and stops the process; the next message or Start launches one that
has it. That is also why the picker is in the session settings dialog beside
Move, and not on the bar beside the model and the mode, which take effect
mid-turn.

`None` is a level in its own right -- the CLI's own default -- so the picker
can return to it, and a blank is normalized to it at the boundary rather
than stored as a level the CLI would reject.

Offered only where it means something: `DriverKind::takes_effort` reports
the capability and the phone leaves the row out entirely, rather than the
session-type branch this app does not have anywhere else. A llama session
would otherwise get a control whose only effect is stopping its process.

Verified on the emulator against the sandbox's fake CLI: the picker sets it,
the server reports it, and an echo session's dialog is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:23:58 -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 e4f0935f98 Keep the second auth test under a subscriber, so the tripwire is not flaky
`gates_every_route_and_never_logs_the_token` failed about one full-suite
run in ten, on the assertion that a rejection *was* logged. Its sibling
ends with an unauthenticated request of its own, made with no subscriber
on that thread -- and tracing caches a callsite's interest process-wide
the first time it is reached, so whichever test got there first decided
whether the warning would ever be recorded.

That is the rule already written at the top of "Things that have bitten",
applied to one member of a set: the combined gating+logging test exists
because of it, and the enrollment test added later did not get it.
Twenty runs clean since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:58:11 -04:00
iris 3c0214ece8 Merge branch 'main' of git.arirex.me:iris/ai-app
# Conflicts:
#	AGENTS.md
#	PLAN.md
#	app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt
#	app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt
#	server/src/config.rs
#	server/src/main.rs
#	server/src/routes.rs
#	server/src/session/echo.rs
#	server/src/session/llama.rs
#	server/src/session/transport.rs
#	server/src/ssh.rs
#	server/src/usage.rs
2026-09-04 17:56:50 -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 Opus 5 127b25e60a Meter a session by its provider, and let llama.cpp run over ssh
The rate-limit bar answered a question about an account, and picked the
answer by machine. One machine runs echo, the Claude CLI and a local
model side by side, so every echo session on it drew the CLI's five-hour
window: a quota that session cannot spend and could never run down. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too so
the two lists cannot disagree), and the phone matches on machine *and*
provider. Nothing meters echo or llama, and nothing at all is drawn --
including while the first fetch is out, since "checking" under a session
that turns out to meter nothing is a row the screen then withdraws.

Echo gets a meter it can be *told* about instead: `/usage 42`,
`/usage 95 20`, `/usage 42 never`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. Those states cost
real quota to arrange, which is why none of them had been looked at.

And llama.cpp runs wherever a setup says, which was the last of phase 5.
`Transport::reserve_port` is the second half of what a transport is --
"run this" plus "reach this port" -- returning the port the server binds
there and the port that reaches it here, and `Launch::reaching` puts the
`-L` tunnel on the connection that already carries the command. Three
things that came out of building it:

- A forwarded launch gets a pty and every other one keeps `-T`. Killing
  the ssh client ends a CLI by closing the stdin it reads; llama-server
  never reads its stdin, so the same kill left it running on the far
  machine with the model loaded -- one orphan per stopped session.
- The model is looked for on the machine that will serve it, at that
  machine's own models directory, so `GET /setups/{id}/models` is what
  the spawn screen offers rather than the backend's own downloads.
- The readiness poll watches the process, not only the port: a model
  that will not load exits in a second and would otherwise have been
  reported as "gave up after 300s". The failure carries the log's tail.

Exercised end to end against this VM over ssh to itself: spawn, load,
answer, outlive a backend restart, be adopted, answer again, and stop --
with both the ssh client and the far llama-server gone afterwards. The
local path, the Claude bar and the spawn screen checked on the emulator.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:15:15 -04:00
333 changed files with 75619 additions and 10026 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
# every crate in this repo into one workspace -- they are deliberately
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
# from the repo root runs xtask/src/main.rs directly.
[alias]
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
+244
View File
@@ -0,0 +1,244 @@
---
name: ai-app-rigs
description: ai-app's test rigs, harness scripts and reference measurements - ui-sandbox.sh, debug-transcript.sh, transcript-bench.sh, stream-bench.sh, trace-draw.sh, the /usage fixture vocabulary, the fake CLI, the rule that no UI-driving script may tap a coordinate, how to test llama.cpp and ssh on this machine, how importing behaves, and the scroll/stream/explorer numbers not worth re-measuring. Read before running or writing a benchmark, driving the app's UI from a script, exercising the session lifecycle, testing a llama or remote session, or touching the import screen.
---
# ai-app: rigs, harnesses and measurements
Moved out of `AGENTS.md` on 2026-09-04 so it is read when it is relevant
rather than sent with every request in this repo -- it was 12 KB of the 35 KB
that file cost on every one. Unchanged in the move, and still the only copy.
## The rigs
Each exists because something was invisible without it.
- **`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.
- **`/usage` in an echo session puts up an invented meter**, which is how the
rate-limit screens' states are reached without spending quota: `/usage 42`,
`/usage 95 20` (minutes left), `/usage 42 never` (the between-blocks window
with no reset time), `/usage 42 unreadable`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. The vocabulary is
`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.
- **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.
### 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
**Both are set up here as of 2026-09-04** and need nothing typed. The
prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp`
(the 15 MB `ubuntu-x64` release asset) and is symlinked as
`/usr/local/bin/llama-server`, which is what makes **discovery find it over
ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets.
It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is
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
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**. That is set up
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
setup called **"this vm over ssh"** — `bob@127.0.0.1` with that
`identityFile` plus
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
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
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`
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
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.
## Importing
The import list reports each session's **size as well as its line count**,
because the two disagree in the way that matters: these transcripts embed
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
count tells you what continuing a session will cost. Shown, not warned about;
importing a large session is a choice somebody is entitled to make.
**Never import a Claude Code session that is open in a terminal.** The app
refuses it — see PLAN.md for the incident that made that a refusal rather
than a warning.
**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.
**Deleting a session offers to take the machine's own transcript with it**
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
confirmation, and only where the driver keeps a record of its own
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
because leaving that copy is what makes an ordinary delete recoverable — and
the dialog's paragraph is rewritten when it is on rather than appended to,
since the sentence promising the conversation "should still be there to
import again" is exactly the one the switch makes false. The server deletes
the machine's copy *first*, so a machine it cannot reach leaves the session
where it was instead of half-deleted.
## Measurements worth not re-taking
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
emulator against a real imported transcript with the server at
`--delay 120`. Settled and flinging fast, both into fresh history and back
through rows already drawn: **5.25.9% janky frames, 99th percentile
2932ms, 02 slow UI-thread frames.** The stock Settings app on the same
device is 3.3% and 38ms, so this is at the platform floor. The number that
is *not* at the floor is the first few seconds after opening a session,
where every row on the way is being composed for the first time; that is
inherent to a lazy list and it is why a measurement taken before the screen
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
- **The reset path is not reachable by reopening a session.** Measured
2026-09-04 against a session streaming at 20 events a second: reopening one
with an anchor 1,800 events back connects **87119 events behind**, well
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
opening page, then one span covering the whole distance. To exercise the
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
the app takes the reset on a live connection, clears, refills and carries
on without reconnecting.
- **The session screen's stream survives backgrounding here** — 20 seconds at
the launcher while 415 events were produced brought no reconnect at all,
which is not what the comment above that loop expects, and is most likely
this emulator being headless rather than the phone's behaviour.
- **Reopening a cached session costs one request for one event** (the probe),
and scrolling the whole conversation back costs nothing more; a cold open
of the same 500-event session is two pages, 100 events. Measured
2026-09-04 on the emulator against the sandbox.
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
28,000-line file because it draws one row per line; the editor is one
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
screen. If you make the editor faster, that number is what to move.
EXPLORER.md's "What the measurements said" has the rest.
+17
View File
@@ -55,4 +55,21 @@ components: [
// the terminal the QR would be printed on.
enroll: "server/enroll-link.sh",
),
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
// with the same release key as "app" above so the two can install
// over each other -- a separate component, not a mode of "app" above,
// because it is a different applicationId (com.example.aiapp.shell)
// built by a different tool from different sources. No `cwd`: it
// defaults to this checkout's root, which both the `cargo xtask`
// alias (`.cargo/config.toml`, resolved relative to the working
// directory cargo is run from) and `cargo xtask apk`'s own publishing
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
// both need.
Apk(
name: "shell",
modes: ["release", "debug"],
build: "cargo xtask apk",
),
],
+19
View File
@@ -1,12 +1,20 @@
.gradle/
build/
app/androidApp/build/
app/shellApp/build/
local.properties
.kotlin/
*.iml
.idea/
.DS_Store
server/target/
event-model/target/
client-core/target/
android-shell/target/
# E3's native library, built by cargo-ndk straight into the Gradle module
# (RUST.md) -- an artifact, like server/target/ above, not source.
app/shellApp/src/main/jniLibs/
# Server logs from a development run (ai-server.log by convention,
# wg-test.log from ./test-wg-tunnel.sh).
@@ -21,3 +29,14 @@ certs/
config.ron
config.json
sessions/
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
iris/target/
iris/android-app/target/
# E5's packaging xtask (RUST.md). `build/` above already covers
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
# The repo root has no Cargo workspace, so this is xtask's own
# intermediate working files (target/xtask/apk/...), not a shared one.
xtask/target/
/target/
+502 -758
View File
File diff suppressed because it is too large. Load diff
+44
View File
@@ -0,0 +1,44 @@
# Decisions awaiting review
Choices made while working autonomously, for Bryan to keep or change. Each
says what was picked and why; the detail is in the design doc it names.
Delete an entry once it has been looked at.
## Subagent views (2026-09-05, `SUBAGENTS.md`)
Made on my own judgement, limited blast radius:
1. **A subagent is a transcript, not a session.** It has no process,
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
and stored under the session's directory, so deleting the session takes
it. Alternative rejected: registering it as a session of its own, which
would give it a card in the main list and a driver that can do nothing.
2. **Read-only view is the session screen minus its controls**, rather than
a second, simpler transcript screen. Keeps paging, caching, selection
and rendering in one place. Cost: a `readOnly` mode threaded through
`SessionScreen`.
3. **The list only carries a count.** Each session row says how many
subagents it has; their titles and statuses are fetched when the card is
expanded. Keeps `GET /sessions` from reading every subagent transcript.
Consequence: an expanded card's statuses refresh with the list, not live.
4. **Expanded/collapsed is remembered per session on the phone**, not on
the server. Collapsed by default, per the transcript convention that new
things arrive collapsed.
5. **Subagents of imported sessions are not shown.** The import path still
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
are not read. Only subagents run while this backend was watching exist.
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
paid turn to exercise.
Deferred, because they reach further than this feature:
- **Live status on the list.** Whether the session list should follow a
stream at all (it refreshes on demand today) decides whether subagent
status can ever be live there. Not changed.
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
in its transcript and are not given transcripts of their own. Supporting
that is the same mechanism one level down, but the UI would need nested
expanders.
- **The subagent status row says "context unknown".** Nothing measures a
subagent's context; the row could leave it out rather than admit it.
-453
View File
@@ -1,453 +0,0 @@
# The file explorer
Asked for by Bryan on 2026-09-03: replace the session screen's debug
button with a folder icon that opens a file and directory viewer for the
machine the session runs on. Browse directories, open files with the
existing syntax highlighting, line numbers, no wrapping; edit a file behind
a pencil icon; create files through a modal like the ones the app already
has; work over ssh; open at the session's working directory.
This is the plan. Like PLAN.md it records each decision with the reason and
what was rejected, so that when one changes it is changed here rather than
re-argued. Once built, the operational notes (how to test it, what bit)
move to AGENTS.md and this file keeps only the design.
## What it is, in one paragraph
A machine's filesystem, seen from the phone through the backend. The
explorer belongs to a **setup** (a machine), not to a session: a session
only says where to start. Every operation -- list, read, write, create --
is one shell script run through `Transport`, exactly the way the import
listing and the usage fetch already work, so the local and the ssh case
are one implementation and a machine the backend cannot reach fails with
ssh's own message. The phone draws what came back: a listing, a file with
its lines coloured by the scanner in `Highlighter.kt`, or an editor over
the same text.
## Decisions
### 1. Keyed on the machine, opened from the session
Routes live under `/setups/{id}/…`, beside `importable`, because a
filesystem is a property of a machine. The session screen's folder button
opens the explorer with the session's setup and its `cwd` as the starting
directory; a session with no `cwd` opens at the machine's home, which the
machine resolves (`cd` with no argument and `pwd -P`), never a path the
phone guessed. Nothing in the explorer knows what a session is, so a later
entry point from the setups tab is one more caller and no new code.
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
find the setup, and "browse this machine" from anywhere but a session would
need a session to exist first.
### 2. One shell script per operation, over `Transport`, on both transports
Each operation is a small POSIX shell script handed to `sh -c script sh
"$path" …` through `Transport::capture` (or the stdin-carrying variant
below). The path and every other value cross as **positional arguments**,
never interpolated into the script -- the same rule `import::find` follows
with `"$1"`, and the same reason `ssh::quote` exists: a path is
attacker-adjacent input in a server whose job is running commands. A `~`
prefix is handled by the same `quote_path`/`expand_home` pair every other
path goes through; nothing new is invented for it.
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
`sha256sum`, `chmod --reference`). That is already what `import.rs`
assumes (`stat -c`, `/proc`), and both machines that exist are Linux. A
machine without them fails with that tool's own message, which names what
is missing.
Rejected: `std::fs` for the local transport and scripts for ssh. Two
implementations of "list a directory" drift -- the ordering of entries,
what a symlink reports, how a permission error reads -- and the local one
is the one that gets tested, so the remote one ships broken. The transport
design exists so that a driver never learns which machine it got; the
explorer is held to the same rule. The cost is a `sh` process per
operation locally, which is under a millisecond.
Rejected: a Rust SSH or SFTP library. PLAN.md rule 23 -- the system `ssh`
inherits `~/.ssh/config`, agents and jump hosts, and there is one place to
configure a connection. SFTP would need a second one.
### 3. The token can now name a path, and that is written down
AGENTS.md says of the import route: "the phone picks an **id**, never a
path: the server resolves which file that is, so an enrolled token cannot
become 'read me an arbitrary file'." The explorer's whole purpose is the
path, so it takes one. This is recorded in PLAN.md's Security section as a
change to the threat model paragraph, in these terms: the token already
gates spawning a bypass-permissions agent in any directory on any machine
a setup names, and that agent can already read and write every file its
user can. The explorer is a shorter path to authority the token already
holds, not new authority. The import route's rule stands where it is,
because there a path was unnecessary and refusing it cost nothing.
What is *not* changed: no route accepts a command. Listing, reading and
writing are fixed scripts; the phone chooses only the path and the bytes.
### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one
Same rule as `POST /sessions/{id}/cwd`: a relative path is refused with
the same wording, because where it would be depends on where nothing the
reader can see. Every listing answers with `pwd -P` of the directory it
listed, so the phone navigates on a resolved absolute path -- the parent
of `/home/bob/repos/ai-app` is a string operation on that, and a `~` the
session was spawned with is shown as what it turned out to be. The phone
never resolves `..` itself.
### 5. A read is capped and typed, and every state it can be in has a word
`GET /setups/{id}/file` answers with one of:
- `text` -- the content, with its size, mtime and sha256.
- `binary` -- the content is not UTF-8. Size reported, nothing shown.
- `tooBig` -- over `FILE_LIMIT` (1 MiB to start; see "Numbers to
measure"). Size reported so the reader knows what they are looking at.
- an error -- no such file, permission denied, machine unreachable --
carrying the machine's message.
Four outcomes rather than content-or-error, because a binary file drawn as
text and a big file cut off silently are both wrong in ways the reader
cannot see, and "couldn't read it" must not look like "it is empty". An
empty file is `text` with empty content and is drawn as one empty line
numbered 1, which is what it is.
Not in the first cut: showing images (the phone has `isImageRef` and a
viewer already; the route would serve bytes). Listed under "later".
### 6. A write is conditional on what the reader saw
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
compares it against the file as it is now and refuses with a distinct exit
code if it differs; the server answers **409** with "changed on the machine
since you opened it". Agents edit files while people read them; this is
the common case, not the exotic one, and silently overwriting an agent's
edit with a stale copy is the worst available outcome. The phone offers
three ways out and says what each costs: **Overwrite** (theirs is lost),
**Reload** (yours is lost), **Cancel** (keep editing, decide later).
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1"
"$1.ai-app-tmp" && mv -f -- "$1.ai-app-tmp" "$1"`, with the bytes on
stdin. A temp file and a rename, so a connection dropped mid-write leaves
the old file whole rather than a truncated one; `chmod --reference` keeps
the mode, which a fresh file would otherwise lose (an executable script
would stop being one). What this trades away: the inode changes, so a hard
link elsewhere stops being the same file. Accepted; editors do the same.
The check-then-write is not atomic against a writer landing between the
two -- a window of microseconds on the same machine -- and that is accepted
too, and noted at the script.
The response carries the new size, mtime and sha256, so the editor's
precondition is fresh without a second read.
### 7. Create refuses to overwrite
`POST /setups/{id}/file {path}` runs under `set -C` (noclobber) and
`: > "$1"`, so a name that exists fails with the shell's own message rather
than truncating somebody's file. `POST /setups/{id}/dir {path}` is `mkdir
--` with the same property. The modal names one thing in the current
directory and has a switch for "directory"; a created file opens straight
into edit mode, because an empty file is not something to look at.
Rejected: create-with-content in one request. The editor is the place
content is typed, and a modal with a text area is a second editor.
### 8. The viewer is a list of lines, coloured once
The file is scanned once, off the main thread, by `scan` in
`Highlighter.kt` with `rulesOf(language)`; the spans are bucketed per line
in one pass, and each line's `AnnotatedString` is built when that line is
composed. A `LazyColumn` of lines, not one `Text`: text layout is linear
in the text, and a 20,000-line file in one `Text` measures all of it to
draw a screenful. Lines are drawn with `softWrap = false` inside one
shared `horizontalScroll` state, so the whole file scrolls sideways as a
block and a line never wraps.
Line numbers are a gutter in each row, right-aligned, with the gutter
width taken from the digit count of the line count in the same monospace
style -- so a 9-line file and a 12,000-line file each get exactly the
width they need and nothing is measured by hand. Because nothing wraps, a
logical line is one visual line, and the gutter cannot drift from the text
it numbers. Gutter numbers take `onSurfaceVariant`; the text takes the
scanner's palette on `rawSurface`, the surface every verbatim thing in the
app already sits on.
The language comes from the file's extension through the same table
`fenceLanguage` reads (`FENCE_LANGUAGES` already keys on `kt`, `rs`,
`py`, …). One function, `fileLanguage(name)`, takes the part after the
last dot and asks that table; it is one table, not two, so a language
added for fences is added for files. A file with no entry is drawn plain,
for the reason the table's comment gives.
Selection: the lines sit inside one `SelectionContainer`, as the
transcript does, so a selection can run across lines.
### 9. The editor is the legacy text field with a highlighting transformation
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the
same monospace style, inside the same horizontal scroll so it does not
wrap, with a `VisualTransformation` that returns the text unchanged and
the scanner's spans as styles (`OffsetMapping.Identity`, since no
character moves). This is the one Compose API that colours a field's text
without replacing the field; the newer `TextFieldState` API has no hook
for styles. The gutter is one `Text` of `1\n2\n…` in the same style beside
the field, aligned for the same reason as the viewer: no wrap, one line
each.
Save is a glyph in the header, **disabled** until the text differs from
what was loaded (never hidden -- a control that comes and goes makes its
own absence the signal), and a `GlyphSpinner` while the write is out.
Back with unsaved changes asks; the question says the edits will be lost.
The keyboard: the explorer draws over the session, which deliberately has
no `imePadding` (see `SessionScreen`'s layout note), so the explorer's own
box adds it.
Re-scanning on every keystroke is the cost to watch. For a file under
`FILE_LIMIT` it is expected to be a few milliseconds (the scanner replaced
a library that took 174ms on 200 lines; ours has not been measured on a
1 MiB file). Measure before deciding whether edit mode needs a size below
which highlighting is on -- see "Numbers to measure".
### 10. The explorer draws over the session, and back closes it first
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
`FilesScreen` is composed **on top of** the session in the same `Box`, and
the session stays composed under it: its event stream keeps flowing, its
scroll position and draft stay where they were, and returning from a file
costs nothing. Back -- the button, the platform gesture and `swipeBack` --
clears `files` when it is set and goes to the list otherwise. Inside the
explorer the same back steps one level: editor → viewer (with the unsaved
question), viewer → listing, listing → parent directory it came from, and
only from the starting directory does it close. "Back returns; it does not
exit."
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from
a leaf screen goes to Main today, and a session disposed and re-created on
each return refetches its transcript over the tunnel -- exactly the flip
between "what did it change" and "what is it saying" this feature is for.
The image viewer already made the same choice for the same reason.
### 11. The listing is drawn as it came, sorted at display time
Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and
whether the entry is a symlink (with the kind being the *target's*, from
`find -printf '%Y'`, so a link to a directory navigates). Sorted on the
phone, stably: directories first, then case-insensitive name. Dotfiles are
shown -- in a repository they are half of what matters. A row is the
glyph, the name, and the size for a file; tapping a directory descends,
tapping a file opens it. Each directory's entries are kept for as long as
the explorer is open, keyed by path, so returning to one does not refetch
it; the header's refresh glyph refetches the current one on purpose, and a
create refetches the directory it created into, since that is what the
operation changed.
An empty directory says "Nothing here". A listing that failed says why,
in the machine's words, where the rows would be -- never an empty list.
Entries are separated by `\0` in the script's output and by `\t` within a
line (`find -printf '%y\t%Y\t%s\t%T@\t%f\0'`), so a filename with a
newline or a tab in it survives; `parse_entries` is a unit test with
exactly those names in it.
### 12. Icons
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script
rerun and its output committed (it needs network):
- `md-folder` U+F024B -- the header button, and directory rows. The same
codepoint dev-updater uses, and it must not drift from it, as the cog
and the refresh arrow already must not.
- `md-plus` U+F0415 -- create. Also dev-updater's.
- `md-pencil` -- edit.
- `md-content_save` -- save.
- `md-file_outline` -- file rows.
The last three are verified against the Nerd Fonts cheat sheet when they
are added, not copied from memory.
### 13. The render report moves, and the benches move with it
The speedometer goes. The report it copies is the standard measurement
`transcript-bench.sh` and `stream-bench.sh` read from logcat, so it stays
reachable: a "Copy render timings" row in `SessionSettingsDialog`, which
is where the session's other about-the-session controls already are.
**No script that drives the UI taps by coordinate, and moving this
button is where that rule gets enforced** (Bryan, 2026-09-03). Both bench
scripts press the button today as `ui-trace record --do 'tap 723 205'`, a
position measured once by hand. Anything that moves the header -- this
change, a font size, a density, another emulator -- makes that tap land on
whatever now sits there, and the script then reports a number that was
never measured, which reads exactly like a result. A control is found by
the name it already carries for assistive technology (`GlyphButton`'s
`label`, a row's text) and pressed at the bounds the screen reports at
that moment.
That belongs in the tool, not in each script: `ui-trace` in
`~/repos/emulator-tools` gains a tap-by-label action (`tap 'Session
settings'`, resolving the element's box from the same uiautomator tree
`elements` already reads, at the moment of the gesture), and both benches
move onto it in the same commit as the button -- cog, then "Copy render
timings" -- so the measurement is never unavailable and never wrong
quietly. `grep -n "tap [0-9]" app/*.sh` is the check that no coordinate
tap is left, and it goes in the emulator-tools README beside the action.
Once the action exists, this rule applies to every script that presses
something on an Android screen, not only these two.
## HTTP surface
Added to the table in `routes.rs`'s module doc:
```text
GET /setups/{id}/dir?path=P entries of directory P, and P resolved
GET /setups/{id}/file?path=P content of file P, or why not
PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
(409 when the file no longer matches ifSha256)
POST /setups/{id}/file {path} create empty; refused if it exists
POST /setups/{id}/dir {path} create; refused if it exists
```
Bodies use `deny_unknown_fields` like every other body here. Paths in the
query string are URL-encoded by `Api.kt`'s existing helper.
```json
GET dir -> {"path":"/home/bob/repos/ai-app",
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false},
{"name":"README.md","kind":"file","size":1234,"modified":1756900000,"link":false}]}
GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":,"sha256":"…","content":"…"}
| {"path":"/…/a.png","kind":"binary","size":45678,"modified":}
| {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":}
PUT file -> {"size":1240,"modified":,"sha256":"…"}
```
Errors: `BadRequest` with the machine's message for a path that is not
there, not allowed or not absolute; the existing 409 variant for the
precondition; `Internal` only for the server's own faults. The message is
what the phone shows, in place, so it is written to be read there.
## Server work (`server/src/files.rs`)
One module, with the same shape as `setups.rs`: the scripts as constants,
one `pub async fn` per operation taking `&Transport`, and the parsing as
pure functions with tests.
1. `Transport::capture_with_input(launch, stdin)` -- `capture` with bytes
on stdin. `ship_attachment` in `routes.rs` builds this by hand today
(an `ssh::command`, a `File` on stdin, `output().await`); it moves onto
the new helper in the same change, so there is one description of
"run this there with this on stdin" rather than two.
2. `list(transport, path) -> Listing`: `cd -- "$1" && pwd -P && find .
-mindepth 1 -maxdepth 1 -printf '%y\t%Y\t%s\t%T@\t%f\0'`. First line is
the resolved path; the rest is entries. `parse_entries` tested with
names containing a tab, a newline, a leading dash and a `'`.
3. `read(transport, path) -> Read`: `stat -c '%s %Y' -- "$1"`, refuse
above `FILE_LIMIT` before `cat` so a 2 GB log never crosses the
tunnel, then `sha256sum -- "$1"` and `cat -- "$1"`, header lines then
bytes; the server splits at the header and decides `text`/`binary` by
`String::from_utf8`.
4. `write(transport, path, expected_sha256, bytes) -> Written`: the
script in decision 6, with a distinct exit code for the precondition
(`exit 3`) that the route maps to 409; anything else is the machine's
stderr.
5. `create_file`, `create_dir`: decision 7.
6. Routes in `routes.rs`, each resolving the setup with `setup_by_id` and
`Transport::for_setup` as `set_cwd` does. The path check (absolute or
`~`) is one function shared with `set_cwd`, which has it inline today.
7. Tests: the parsers; the quoting (a path that tries to close the quote
ends up as one absurd argument -- `ssh.rs` has the pattern); and an
integration test running each script through `Transport::Here`
against a `tempfile` tree, which is cheap because `sh` is there
wherever `cargo test` runs. The precondition test writes the file
between the read and the write and asserts the 409 path.
8. PLAN.md: the Security paragraph from decision 3, and an "Explorer"
section pointing here. AGENTS.md: the layout bullet for `files.rs`.
## App work
1. `Api.kt`: `fetchDir`, `fetchFile`, `writeFile`, `createFile`,
`createDir`, and the three data classes (`DirEntry`, `FileContent`
as a sealed class with the four kinds, `Written`).
2. `NerdIcons.kt` + `build-icon-font.sh`: decision 12.
3. `Languages.kt` (or `CodeFence.kt`, wherever `FENCE_LANGUAGES` sits):
`fileLanguage(name)`.
4. `FileLines.kt`: the pure half of the viewer -- spans bucketed per line,
`lineOf(index) -> AnnotatedString` -- so it has a JVM unit test beside
`HighlighterTest`, the app's one existing test suite, covering a block
comment that spans lines and a file with no trailing newline.
5. `FilesScreen.kt`: the listing, the navigation stack, the per-directory
cache, the create dialog (modelled on `AddSetupDialog`: fields, a busy
state, the failure shown inside the dialog beside the button that
caused it), and the header. `LoadState` for the listing.
6. `FileViewer.kt`: decision 8. `FileEditor.kt`: decision 9, including
the conflict dialog.
7. `AppRoot.kt`: decision 10. `SessionScreen.kt`: the folder glyph where
the speedometer was, `onFiles(setup, cwd)` out to the root.
8. `SessionSettingsDialog.kt`: the render-report row. In
`~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the
two bench scripts onto it, with no coordinate tap left in `app/*.sh`.
## Testing
- **Server**: `./run-tests.sh`, `cargo clippy --all-targets`, `cargo fmt`.
- **Local transport, by hand**: `./ui-sandbox.sh api
"/setups/<id>/dir?path=~"` against the sandbox, whose `$HOME` is a
throwaway tree it is fine to write into. The sandbox gets a small
fixture directory with the states worth seeing: an empty directory, a
file with a tab in its name, a binary file, one over `FILE_LIMIT`, an
unreadable one (`chmod 000`), a symlink to a directory, and a source
file in each of a few languages.
- **Remote transport**: the ssh-to-this-VM recipe in AGENTS.md ("How to
test SSH here"). The point of the exercise is the quoting and the
stdin path: write a file whose name has a `'` in it, and read it back.
- **Phone**: `ui-trace`, not screenshots, for the things this feature is
made of -- that the gutter's number and its line share a baseline at
the first and the last row, that a long line's row is wider than the
viewport and does not grow the row height, that the editor's gutter
stays put while the text scrolls sideways. Screenshots for colour and
contrast on `rawSurface`.
- **States to produce on purpose**, since the default state is the one
everybody looks at: a directory that fails to list (permission),
an unreachable machine (a setup pointing at a dead address), `binary`,
`tooBig`, the 409 conflict (edit the file with `sed -i` on the machine
between opening and saving), creating a name that exists, back with
unsaved edits, and the keyboard up over the editor.
## Numbers to measure, before deciding
- Scan time for a 1 MiB source file on the emulator, and on the phone
through the render report. That decides whether `FILE_LIMIT` is right
and whether edit mode highlights every keystroke or only below a size.
- Time to first line for a 1 MiB file over the tunnel: the read, the
transfer, the scan, the first composition. If the transfer dominates,
the route gains nothing from streaming; if the scan does, it moves to
a worker with the plain text drawn first.
- The `BasicTextField` at 20,000 lines: whether typing stays responsive.
If not, edit mode gets a lower cap than the viewer, stated in the
editor rather than discovered by a stuck keyboard.
## Order of work
Each step leaves the app working and is one commit.
1. Server: `files.rs` with `list` and `read`, routes, tests. Half a day.
2. App: icons, `Api.kt`, `FilesScreen` listing, `FileViewer`, the root
and session wiring, the render-report move with the benches. A day.
3. Server: `write`, `create_file`, `create_dir`, the stdin helper and
`ship_attachment` onto it. Half a day.
4. App: `FileEditor`, the create dialog, the conflict dialog. Half a day.
5. Measurements above, the sandbox fixture, PLAN.md and AGENTS.md. Half a
day.
## Later, deliberately not now
- Delete, rename and move. Destructive controls belong here eventually,
shown and confirmed rather than hidden, but none of them is needed to
read or change a file.
- Images in the viewer, through the existing `SessionImageViewer`.
- Following an agent's edits live: a file open in the viewer refreshing
when a `Write`/`Edit` tool call on the same path lands in the
transcript. The transcript already knows the path.
- Remembering the last directory per session.
- Uploading from the phone into a directory. Attachments already do the
upload half; this would be the same route with a chosen destination.
- Search within a file, and find-in-files.
-1166
View File
File diff suppressed because it is too large. Load diff
+141
View File
@@ -0,0 +1,141 @@
# Subagents
A session's subagents -- the helpers a Claude Code session starts through its
Task tool -- each get a transcript of their own, listed under the session's
card and readable in the same transcript view the session has. Designed
2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`.
## What a subagent is here
**A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls.** It is not a session: it cannot be
messaged, stopped or started, and it has no setup, model or usage of its
own. Everything it shares with a session -- the transcript file format, the
paging routes, the SSE stream, the phone's cache and rendering -- is reused
by addressing, not by copying.
The CLI reports a subagent's messages on the parent's own stream-json
output, each carrying `parent_tool_use_id` = the id of the Task `tool_use`
that started it. Before this the translator dropped those lines
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
them to that subagent's own translator and transcript. The parent's
transcript still shows only the Task call itself.
## Storage
Under the session directory:
```
<session>/subagents/<tool_use_id>/meta.json {title, created}
<session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's
```
The id is the Task tool_use id (`toolu_…`), which is unique, stable across a
backend restart, and already the key everything on the parent side uses.
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
id becomes a path.
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
`read_window`, `catch_up` and `read_after` work on it unchanged.
Its path out: deleting the session deletes its directory, subagents included.
There is no separate delete.
## Lifecycle, as events in the subagent's transcript
1. Created on the first child line for an unseen parent id (or, when the
parent Task call was seen, at that call). First lines written:
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
the prompt is known -- it genuinely is the subagent's first user turn.
2. Every child line is translated by that subagent's own `Translator`
(one per subagent: tool ids are unique but streaming deltas are by
content-block index, and parallel subagents interleave).
3. **The parent's `tool_result` never finishes a subagent.** The Task tool
runs in the background by default: the `tool_result` -- "Async agent
launched..." -- arrives the moment it *starts*, while the subagent goes
on working for however long its own turn takes, sometimes minutes. What
ends it is its own turn ending: the raw API's `message_delta` on its
stream carrying `stop_reason: "end_turn"` (a `stop_reason` of `tool_use`
is the model about to call one, not an end), or a `result` line for its
own turn if a future CLI version ever sends one. Either maps to
`Status Exited`; the subagent's vocabulary has no `Idle`, so the
equivalent event `dispatch` produces for an ordinary session is dropped
rather than written. A shipped version of this finished on the
`tool_result` instead, which read a running background agent as
"finished" with its transcript truncated at the moment it launched.
4. **A child line for a subagent that already finished reopens it**
(`Status Running`) rather than being dropped: a background Task can be
sent another message long after its first turn ended, and that is
exactly what a further line for it means. Same transcript, same child
`Translator`, just picking back up.
5. When the parent session's process exits (`Status Exited` on the
session), every subagent still `Running` gets `Status Exited` too: its
process was the parent's.
A subagent that was mid-flight when the backend restarted keeps working:
the registry reopens the existing transcript on the next child line, and
the file continues its sequence -- the same reopening #4 describes, whether
what closed it was a restart or its own `end_turn`. If its turn ended while
the backend was down nothing recorded that until the next line arrives, so
its last status stays `Running`, which the list reports as **unknown**
rather than as running (see the wire shape) until then.
Title: the Task call's `description` input, then ` (<subagent_type>)` when
one is given; falling back to the tool's name when the child arrives before
(or without) the parent call being seen.
## Server layout
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
- `session/claude/translate.rs` -- routes child lines by parent id, holds
one child `Translator` per subagent, remembers pending Task calls'
description/prompt/subagent_type.
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
subagents at once, each named "helper k". Each writes the prompt as its
user message, streams a few words of text, runs one `Bash` tool call, then
finishes about three seconds after starting, and the parent's Task calls
end when their subagent does. Three seconds so the running state can be
seen on the phone.
- `routes.rs` -- three routes, in the doc table.
## Wire shape
```
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
GET /sessions same field on each row
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
```
`status` is the transcript's last `Status` event, serialised like a session's
(`running`, `exited`), except that a subagent whose session is not itself
running cannot be running: the list answers `unknown` for that one. The
phone words these as *running*, *finished* and *unknown* on the subcard.
The count on `SessionInfo` is a directory listing, so the list stays cheap.
The per-subagent status is only read when the list route is asked for.
## Phone
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
`Pointing.Up` -- collapsed by default. Expanding fetches
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
indented inside the session card, the way dev-updater draws a project's
components: title, then the status word and a relative time. The
expansion state is per session id and survives a refresh of the list.
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
**read-only** form: the same transcript, paging, cache, selection,
images and status row, with the composer, the process button, the model
picker, the files button, the settings cog and the usage bar left out.
The header shows the subagent's title with the session's title beneath
it. Back returns to the list.
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
cache take a transcript address rather than a session id --
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
subagent's copy under its session's and the same code serves both.
-41
View File
@@ -1,41 +0,0 @@
# TODO
Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate
one in place when it turns out to need a decision.
## App — transcript
- [ ] Text inside code blocks does not highlight when selected. **Measured, and
it does** — the selection is drawn, but over the near-black surface a code
block and a tool's output sit on, Material's default 40%-alpha tint
composites to a barely-there smudge, much weaker than the same selection
over a reply. The app now states its own selection colours
(`AiAppSelectionColors`), which took the fill from #5B4C73 to #776394 on
that surface. Worth confirming this was the complaint rather than a
selection that draws *nothing* on the phone.
- [ ] Text inside an opened peer message or memory note cannot be selected at
all — the heading of the same card can, and so can a tool call's output,
so it is the markdown text specifically. Pre-existing (measured against
the build before this session's changes, by stashing them). It
contradicts AGENTS.md's "all transcript text is selectable".
- [ ] Messages received from other agents are inconsistent — sometimes they
appear, sometimes they don't. **Needs a rig.** Read the code rather than
measured: a live Claude session only learns of a peer message from the
`origin` object on a turn's `result`
(`session/claude/translate.rs`), which the CLI attaches to a turn the
message *started*. So a message that arrives mid-turn, or a second one
within one turn, has nowhere to be reported — while an imported session,
which syncs from the CLI's own file, picks up every one of them. That
would show exactly as "sometimes". Confirming it means driving a real
stream-json session and sending it messages in both states.
## Session settings
- [ ] Autocompact belongs in session settings; empty disables it, which is the
default. **Needs a decision before building** — nothing called autocompact
exists yet on either side. `PLAN.md` has it only as a planned pi-driver
feature (`set_auto_compaction`), and Claude Code runs its own. So this is
a new server feature, and the open questions are what the empty-or-not
value *is* (a token count? a percentage of the context window?) and which
drivers it applies to.
-265
View File
@@ -1,265 +0,0 @@
# Transcript rendering: what was learned, and what is next
Written 2026-09-03 at the end of a week of work on the session screen's
transcript, so the next session can start from here rather than from a
compacted context. Work that is finished lives in "the architecture, as
built"; the running log of how each piece got there has been dropped. `AGENTS.md` holds the one-paragraph conventions; this is
the longer record: the measurements that drove each decision, the
techniques that worked, the ones that did not, and the order to do the rest
in. `PLAN.md` remains the design source of truth; nothing here contradicts
it.
## The goal, and where it stands
A reply of any length must scroll at the phone's 120Hz without a bump, and
must keep doing so while the reply is still streaming in. Measured on the
Pixel 9 Pro XL by Bryan, the transcript went from visible stalls at long
replies and at lists of links to "I have to actually try to feel any
bumps". The remaining work is finish and extensibility rather than
performance.
## The architecture, as built
Everything below lives under `app/androidApp/src/main/kotlin/com/example/aiapp/`.
**Rows become units, and units are bounded.** `TranscriptUnits.kt` turns a
transcript row into the things the lazy list actually holds. An assistant
reply is not one unit: it is one unit per piece of its markdown, so the
list composes and draws a paragraph, a fence, a table or one bullet at a
time. The reason is the draw phase: a row's display list holds every glyph
of it and is re-recorded whenever drawing is invalidated, and the lazy list
composes an item whole in the frame it scrolls into. The tallest single
row still being drawn before this was 36,982px, twenty-five screens in one
message. Long user messages are sliced the same way (`UserChunk`), through
the shared `cardPiece` modifier that draws one card in lazy-list pieces.
**One parse per message, addressed by piece.** `MarkdownPieces.kt`'s
`Piece(block, item)` is an address into the message's single parse tree,
not a substring: `block` indexes the root's children and `item` one
`LIST_ITEM` of a top-level list. Cutting was originally done by
re-parsing substrings, which cost a parse per piece and broke reference
links defined at the foot of a message. `ParsedReplies` caches the parse
and the piece list per text (`of`, `piecesOf`), warmed off the composing
thread by `TranscriptItems.warm`. The parser is still intellij-markdown via
the mikepenz renderer, but its `Markdown()` composable is not called at all:
`MarkdownRoot` in `Markdown.kt` provides the `Local*` environment itself --
reference links from the parse, padding, dimens, colours, typography, a
no-op image transformer, animations, components -- and `MarkdownElement`
dispatches a whole block through our component table. Nothing between a
piece and the screen is the library's now except the leaf composables that
table names.
**Lists are drawn an item at a time, by us.** The renderer has no element
for a single list item, so `MarkdownListItem` draws one: marker, then the
item's children, nested lists recursing through `MarkdownList`. The
marker is drawn in one place on purpose; styled bullets per depth go
there.
**Links are spans, not nodes.** `MarkdownLinks.kt`. Compose turns every
`LinkAnnotation` into a layout node (clipped, focusable, hoverable,
clickable, outline recomputed from the text layout). A paragraph of eight
links was nine nodes, and measured against the same paragraphs with each
link replaced by plain words it cost 26.3ms worst measure against 5.2ms,
1.7x the place time. That was the bump at a reply's list of sources.
`LinkedText` builds the annotated string with the renderer's own inline
builder but answers links itself: colour, underline, a string annotation
carrying the URL, and one tap detector for the whole text that asks the
layout which glyph is under the finger. Hit-testing must check the glyph
on either side of the returned caret, because `getOffsetForPosition`
returns the nearest boundary; taps on the right half of a glyph otherwise
open nothing. Headings need the `ATX_CONTENT`/`SETEXT_CONTENT` child, since
the inline builder draws nothing for a node type it does not know (a week
of blank headings). Tables go through `LinkedTable`/`LinkedTableRow` so
cells get the same treatment.
**Text draws on the platform directly.** A paragraph without an image
skips the renderer's `MarkdownText`, which charges every paragraph for the
possibility of inline images (placement callback, derived inline-content
map, semantics group, size animation). Paragraphs that contain an image
still take the renderer's path.
**Tables spread or scroll without subcomposition.** The renderer used
`BoxWithConstraints` to decide; `LinkedTable` uses
`fillMaxWidth().horizontalScroll().layout { }` -- `horizontalScroll`
passes `minWidth` through and lifts `maxWidth` to infinity, so the inner
layout reads `minWidth` as the room available and takes
`max(minWidth, columns * cellWidth)`.
**A streaming reply is reparsed one block at a time.** `LiveParse` in
`Markdown.kt` freezes every finished top-level block with its parse and
reparses only the tail block per delta. Markdown's block rules make later
text unable to alter an earlier block, with the single exception of a
late reference definition, which is accepted. Measured on a 58-word stream
of list, fence, table and quote: 47 tail reparses at 1.7ms mean. A
single-list stream would reparse the whole list per delta, since it is one
tail block; that is what the rule below cuts.
**A streaming list becomes a unit per item.** `LiveParse.advanceTo` cuts at
the last item of a multi-item list (`openPiece`), provided that item has
content beyond its marker -- a bare `-` is an empty item now and the first
character of a paragraph line once `-x` arrives, so cutting on it would draw
that line as a new item. The cut is at the start of the item's line, so the
indentation the reparse reads its nesting from survives. `Segment.continues`
marks a tail that carries on a list, and `MarkdownPiece`'s
`continuesList`/`listContinues` keep an inner item's padding at the seam, so
nothing moves when the seam does. Forty linked bullets streamed a word at a
time went from 2412ms of reparsing to 674ms, and `record: one block` from
1.8ms worst to 0.7ms.
**Fences are highlighted off the drawing thread, and a fence still being
written is drawn plain.** `Highlighter.kt` holds `highlight` and the scanner
behind it (shared with a tool call's input, so the same code is the same
colours wherever it appears); `CodeFence.kt` holds the `fenceLanguage` alias
table and `fenceContent`. A word not in the table stays plain, because a
fence coloured by the wrong language's rules looks highlighted and is wrong
in a way the reader cannot see. Highlighting is warmed and cached exactly as
parsing is (`ParsedReplies.highlighted`, filled by `warm` from
`fences(parse)`), and `highlight` takes no colour from the theme, which is
what lets it run off the drawing thread: a two-hundred-line Kotlin fence
costs 15ms to scan on the emulator's debug build -- it cost 102ms through
the library that used to do this -- and a `remember` inside the fence was
charged that again every time the block scrolled back into composition. Because the warming has to ask for the same
string the drawing does, `fenceContent` extracts the code and the language
word itself -- two extractions would be two keys, and the warmed answer
would be missed at every fence with nothing saying so. A fence still
arriving is the same stall in a second place, and warming cannot reach it:
the tail was re-lexed at every delta, on the composing thread, for colours
on text being replaced as fast as they were computed -- 211 lexes and 13.7
seconds across one turn. So `MarkdownRoot`'s `streaming`, true only for a
live reply's last segment, draws the block plain until it freezes; a
finished fence colours as soon as the next block starts, and the settling
lex happens once, in `warm`.
**Markers, and images.** `MarkdownListItem`'s `Marker` draws the bullet by
depth, cycling past the third, in `listMarkerColor` (Theme.kt). The colour
is the same at every depth on purpose: depth is said by the glyph and the
indent, and a colour per depth would make a difference in degree look like
one in kind. The app has no image loader and the renderer's transformer was
the no-op one, so an image in a reply drew as *nothing at all*; an `IMAGE`
node is now appended by `appendPlainLink` as a link carrying its alt text
(the address when there is none), which says what was there and opens it.
**Expansion anchors the edge that was tapped, and the list never moves
under the reader** except when pinned to the bottom with new content
arriving. Those two rules are in `ScrollAnchor.kt` and `TranscriptList.kt`
and are the reason several tempting simplifications were rejected.
## Techniques and harness
- **`app/ui-sandbox.sh`** starts a second `ai-server` against a sandbox
home with the echo driver, so nothing touches real sessions.
`spawn [title]` makes an echo session and prints its id; `send SID text`
or `send SID @file` sends into it; `api /path [curl args]` is an
authenticated request. Restarting it regenerates the config but keeps
enrolled tokens.
- **The echo driver is the test rig** (`server/src/session/echo.rs`, the
list at the top of the file). `/stream N`, `/mixed N`, `/table N`,
`/tools N gap`, `/ask`, `/peer`, `/compact`, `/slow`, `/bash command`
each produce a shape the real CLI produces only when it feels like it.
Build what a UI test needs into it rather than spending model turns.
- **`app/transcript-bench.sh`** is the standard measurement: restart, open
the first session, scroll, print the render report. The report is what
the "Copy render timings" button copies and also logs
(`adb logcat -d -s ai-app:I`), and it includes the last crash's stack
(`CrashLog.kt`), which is how a crash on the phone reaches a session
here.
- **`app/stream-bench.sh [-k] FILE`** is `transcript-bench.sh` for a reply
still arriving: opens the first session, taps "Jump to latest" so the list
is pinned to the newest end, resets the report, sends FILE, waits for the
transcript to stop growing, prints the report. Both of those last two are
corrections to a first version that measured nothing -- a transcript parked
further back never redraws while a reply streams into it, and a session is
idle at *both* ends of a turn, so polling for idle answers before the turn
has started. Fixtures live in `/tmp` and are regenerated from the shapes
named here: `fixture.md` (lists four deep, ordered and nested, fences in
kotlin/rust/sh/none, a table with a link, a quote with a list, an inline
and a standalone image, a reference link), `longfence.md` (200-line Kotlin
fence), `longlist.md` (40 linked items).
- **Two traps in the emulator loop**, each of which cost a bench run.
`adb shell pm clear` removes the enrolment and the notification permission
along with the saved anchors, so the next run measures a permission
dialog; re-enrol with the command `ui-sandbox.sh` prints and
`pm grant ... POST_NOTIFICATIONS`. And a saved anchor is per session id,
so the only way two builds start a scroll from the same place is a *fresh
session for each*.
- **`DebugStats`/`FrameStats`** time our own phases (`record: one block`,
`measure: the app root`) and count events (`markdown reparsed while
streaming`, `markdown cut into pieces`). Add a counter before guessing.
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
framework, via `atrace` text output, no trace processor needed. It is
how the link-node cost was attributed.
- **`app/debug-transcript.sh`** loads a real Claude Code conversation onto
the emulator; two faults were invisible on fixtures and obvious on it.
Real transcripts are private: fixtures stay in `/tmp`, never in the repo.
- **`ui-trace`** reads the screen as text. Bounds print as
`x1,y1..x2,y2`; unanchored `-m` patterns match labels, anchored ones do
not. A row taller than the viewport reports clipped bounds, so compare
screenshots for that case.
- **Emulator frame times are not app measurements.** Software rendering
puts the stock Settings app at 60ms of UI-thread traversal per frame.
Costs of operations in milliseconds rank correctly; smoothness itself is
judged on the phone.
- **System Tracing on the phone does not work on GrapheneOS.** Its
Categories list is empty because the tracing daemon builds it by running
`atrace --list_categories`, which returns nothing there, and a recorded
trace contains zero ftrace events: no app sections, no frames, no
scheduling. Callstack sampling records, but the app's profiler config
unwinds one process shard in four. GrapheneOS issues 2206 and 6094 are
open on exactly this. Until they close, phone numbers come from the
render report and from Bryan noticing.
- **Compose `DropdownMenu` in an edge-to-edge activity** needs
`PopupProperties(clippingEnabled = false)` or it opens a status bar's
height away from its anchor (`~/.claude/TOOLCHAIN.md`).
- **The syntax highlighter is ours: `Highlighter.kt` and `Languages.kt`.**
One left-to-right scanner with a small state -- in a line comment, in a
block comment, in a string, or in ordinary code -- and a `Rules` row per
language, so a new language is a table entry rather than code. Every span
is emitted by advancing an index, so spans cannot overlap, arrive out of
order or run backwards, and an unterminated string or comment simply runs
to the end of the code. `HighlighterTest.kt` is the JVM unit test
(`./gradlew :androidApp:testDebugUnitTest`); the cases in it are the
library's mistakes, kept as regressions.
It replaced dev.snipme:highlights 1.1.0 on 2026-09-03, which found
comments before it knew the language and paired `/*` with `*/` by
ordinal. That library used one set of delimiters for every language, so
`//` in any URL commented out the rest of its line (in `curl
https://example.com/x && echo done` the comment ran to the end and took
`echo` with it, and in Kotlin `val url = "https://..."` the string
disappeared inside it), every Rust `#[derive(...)]` greyed out as a
comment, a `#` inside a Kotlin string swallowed the line, and `x '*/a/*'`
in shell yielded `start=6, end=5` -- a range `AnnotatedString` rejects,
which crashed a card holding `-path '*/.git/*'`. Comments were located
before strings and won over them, so post-processing could not recover
what a wrong comment range had already suppressed. The scanner is also
about seven times faster on the same fixture, and it colours RON, TOML,
fish and JSON, which the library did not know at all.
## Rejected, and why
- **Writing our own markdown renderer.** Rejected in favour of keeping
the intellij-markdown parser and the library's inline builder while
owning block dispatch and the leaf composables. The parser is the hard
part and is not the slow part; everything that was slow lived in the
composables, which are now ours.
- **Re-parsing substrings per piece.** Cost a parse per piece and broke
foot-of-message reference links. Replaced by addressed pieces of one
parse.
- **Animated or timing-dependent corrections.** Anything the reader could
catch at 120Hz is a bug; corrections must be structurally impossible to
see.
## What is next, in order
1. **The reconnect loop.** Restarting the app onto a session with a saved
anchor while a long reply was streaming left it reconnecting every 1.5s
(`RECONNECT_DELAY_MS`), spinner up, until the server was restarted.
`events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers `reset`
plus the newest 200 *raw* deltas -- a window starting mid-message -- and
the reset clears `items`, which is the state the restore loop then pages
against. The restore's one-event-per-request bug was part of what made it
so visible and has been fixed; whether this survives that fix is the
first thing to find out.
2. **Regression runs.** `transcript-bench.sh` and `stream-bench.sh` before
and after any change to the files above, with the report in the commit.
The numbers to watch are the worst `record: one block`, the reparse mean
while streaming, and the draw phase's accounting line.
+1081
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "android-shell"
version = "0.1.0"
edition = "2024"
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
# why those two classes cannot be anything but Java/Kotlin, registered from
# the manifest by name). Everything they would otherwise have done in
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
# picking a session for a share -- is here instead, built on `client-core`
# so the networking and parsing are not duplicated a third time next to the
# server and the Kotlin app.
#
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
# on a normal host target without an Android NDK toolchain, the same
# posture `client-core` and `server` already have.
[lib]
name = "android_shell"
crate-type = ["cdylib", "lib"]
[dependencies]
client-core = { path = "../client-core" }
jni = "0.22"
log = "0.4"
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
# without a backend installed -- so without this, every recoverable error
# at a native entry point would be silently dropped rather than reaching
# logcat. Android-only: nothing else here needs it, and it does not build
# off-device (see `notify::ensure_logger`'s call site, the only place this
# is used).
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.15"
+152
View File
@@ -0,0 +1,152 @@
//! Thin wrappers around the five `Env` calls this crate makes constantly
//! (a class name, a method name and a signature, all as plain `&str`).
//!
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
//! here uniformly rather than switching to the compile-time `jni_str!`
//! literal macro call by call -- these are a handful of short, one-off
//! lookups, not a hot loop, so the difference is not worth two code paths
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
//! `FieldSignature`, which is why those go through
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
//! parsed form is what lets these calls skip re-validating the signature
//! against the arguments on every call, which is the whole reason `jni`
//! moved to it.
//!
//! **The classloader gotcha, found by testing (2026-09-05).** A class
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
//! `get_static_field` -- anything that resolves a *class*, as opposed to
//! `call_method` on an object it already has, which needs no such lookup)
//! defaults to `FindClass`'s ordinary search when it cannot find the
//! calling thread a classloader through `Thread.getContextClassLoader()`.
//! That default is fine on a thread the JVM itself started -- an
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
//! from `android-shell`'s own background thread (the notification
//! follow-loop, the share upload) is running on a thread *Rust* spawned
//! and attached with `JavaVM::attach_current_thread`, which the platform
//! never gave an app classloader. Framework classes
//! (`android.app.Notification$Builder`, ...) still resolve, because they
//! are reachable from the bootstrap loader `FindClass` falls back to --
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
//! packaged inside this app's own APK. The failure was
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
//! as "failed to resolve Java class ... (class not found or linkage
//! error)" -- on a real device this reads as "the notification silently
//! never arrives," since the whole call is inside the follow loop and the
//! ongoing foreground notification (built on the main thread, in
//! `try_start`, before the background thread exists) posts fine either
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
//! first time any entry point has a `Context` to ask, and every class
//! lookup below goes through it explicitly via `LoaderContext::Loader`
//! rather than the thread-dependent default -- so it is correct on the
//! main thread and on this crate's own background threads alike.
use jni::Env;
use jni::errors::Result;
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
use jni::refs::{Global, LoaderContext};
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
use jni::strings::JNIString;
use std::sync::OnceLock;
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
/// Caches `context`'s own `ClassLoader`, the first time this is called.
/// Cheap to call from every entry point that has a `Context` on hand
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
/// a `OnceLock::get` and nothing else.
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
if CLASS_LOADER.get().is_some() {
return Ok(());
}
// context.getClass().getClassLoader() -- resolved via `call_method` on
// real objects throughout, so this needs no class-name lookup of its
// own and has nothing to bootstrap.
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
let loader_obj = call_method(
env,
&class_obj,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)?
.l()?;
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
let global = env.new_global_ref(&loader)?;
// Lost the race with another entry point calling this concurrently --
// both loaders name the same app, so either one is fine and there is
// nothing to reconcile.
let _ = CLASS_LOADER.set(global);
Ok(())
}
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
/// through the cached app classloader when one has been remembered, and
/// through the ordinary default otherwise -- which is every call made
/// before any entry point has run, and is also correct for a main-thread
/// caller, so there is no case this makes worse.
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
match CLASS_LOADER.get() {
Some(loader) => {
let binary_name = name.replace('/', ".");
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
}
None => env.find_class(JNIString::new(name)),
}
}
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
resolve_class(env, name)
}
/// A new Java string as a plain `JObject` -- what every call site here
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
/// `JString: Into<JObject>` is the documented way across).
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
Ok(env.new_string(text)?.into())
}
pub fn new_object<'local>(
env: &mut Env<'local>,
class: &str,
sig: &str,
args: &[JValue],
) -> Result<JObject<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.new_object(class, sig.method_signature(), args)
}
pub fn call_method<'local>(
env: &mut Env<'local>,
obj: &JObject,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
}
pub fn call_static_method<'local>(
env: &mut Env<'local>,
class: &str,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
}
pub fn get_static_field<'local>(
env: &mut Env<'local>,
class: &str,
field: &str,
sig: &str,
) -> Result<JValueOwned<'local>> {
let sig = RuntimeFieldSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.get_static_field(class, JNIString::new(field), sig.field_signature())
}
+131
View File
@@ -0,0 +1,131 @@
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
//! package comment for what this crate is and RUST.md's E3 entry for the
//! design decisions.
//!
//! Each native method is declared with `jni`'s [`native_method!`] macro
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
//! the macro derives the mangled export name and the JNI signature from the
//! Rust function itself, so the two cannot drift apart the way a
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
//! routinely do. `error_policy = LogErrorAndDefault` matches
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
//! JNI call that threw) is reported to logcat, not thrown back into Java
//! as an exception that would crash the app over something recoverable.
//!
//! Each `const _: NativeMethod = native_method! { ... };` binding is
//! otherwise unused by name -- `_` is the idiomatic way to keep a
//! side-effecting const (here, generating the `#[export_name]`d function
//! the JVM resolves by the JNI naming convention) without a `dead_code`
//! warning for a binding nothing reads.
mod jcall;
mod notify;
mod settings;
mod share;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JClass, JObject};
use jni::sys::jint;
use jni::{Env, NativeMethod, native_method};
/// Installs the `log` backend that routes to logcat, once per process.
/// Without it, `LogErrorAndDefault` (every native method below) and any
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
/// call into the `log` facade's default no-op logger, and a real failure
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
/// crashing, since nothing on screen or in the log says a notification was
/// dropped. Called from every entry point below rather than a Java-side
/// `Application.onCreate`, since this crate deliberately has no such class
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
fn ensure_logger() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
#[cfg(target_os = "android")]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("android-shell"),
);
});
}
// The parameters are spelled as their Java types, not as `JObject`: the
// macro encodes each argument into the exported symbol's JNI signature
// (and JNI resolves `Java_...` names *by* that signature), so a generic
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
// method actually declared `(Landroid/app/Activity;...)` -- two different
// symbols that never resolve to each other, silently, with no compiler
// error on either side. `android.app.Activity` etc. have no dedicated
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
// implementation functions below (the "Built-in Types" note in
// `native_method!`'s docs).
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.MainActivity",
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
error_policy = LogErrorAndDefault,
};
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
fn native_handle_intent<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
activity: JObject<'local>,
intent: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &activity)?;
share::handle_intent(env, &activity, &intent)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_sync(context: android.content.Context) -> (),
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
/// enrollment may have just landed) and from `NotificationService.sync`
/// itself. See `notify::sync`.
fn native_sync<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
context: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &context)?;
notify::sync(env, &context)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_start_command(service: android.app.Service) -> jint,
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
fn native_on_start_command<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
service: JObject<'local>,
) -> Result<jint, jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &service)?;
Ok(notify::on_start_command(env, service))
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_destroy() -> (),
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
fn native_on_destroy<'local>(
_env: &mut Env<'local>,
_class: JClass<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
notify::on_destroy();
Ok(())
}
+556
View File
@@ -0,0 +1,556 @@
//! Where a notification is said, and the foreground service that keeps
//! the connection open while the app is closed. Ported from
//! `Notifications.kt`'s `NotificationService`, minus the "session on
//! screen" / "hand to the app as a banner" branches: those read
//! process-wide state that only exists because a screen is drawn to
//! register against, and this experiment draws no screen yet (that is
//! E4's job, on iris). So every notification here takes the third branch
//! Kotlin's `show` already had -- the platform's own drawer -- which is
//! also exactly the case E3's pass condition asks for: **a notification
//! arrives with the app closed.**
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use client_core::api::UreqTransport;
use client_core::notifications::{SessionNotification, follow_notifications};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JValue};
use jni::sys::{JNI_TRUE, jint};
use crate::settings::{self, ServerSettings};
const ALERT_CHANNEL: &str = "sessions";
const ONGOING_CHANNEL: &str = "connection";
const ONGOING_ID: i32 = 1;
const ALERT_ID: i32 = 2;
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
/// Whether the follow-loop thread is already running. **A deviation from
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
/// on *every* call, with nothing to notice a previous one is still going --
/// and `sync()` calling `startForegroundService` when the service is
/// already running is an ordinary Android start, not a restart, so
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
/// `sync` once itself, then again inside `handle_enrollment` after saving
/// the token) hits exactly this path and was observed opening **two**
/// concurrent connections to `/notifications` from one process -- caught
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
/// thread ai-app-notifications` lines for one enrollment. Guarded here
/// rather than left to match Kotlin's behaviour exactly, since duplicating
/// a live connection is a resource leak with no upside; worth carrying the
/// same guard back to `Notifications.kt` separately.
static RUNNING: AtomicBool = AtomicBool::new(false);
/// Set by `nativeOnDestroy`, checked by the follow loop between
/// reconnects. **Known gap, recorded rather than hidden**: unlike
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
/// can interrupt a `ureq` read already blocked inside one connection --
/// `Transport::stream` hands back a plain `Read` with no cancellation
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
/// practice this is a bounded wait rather than a hang; closing that gap
/// for real means adding a cancellation point to `client_core::Transport`,
/// which is a decision affecting every caller of that trait, not just this
/// one -- left for whoever next depends on prompt shutdown.
static STOPPING: AtomicBool = AtomicBool::new(false);
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
crate::jcall::get_static_field(env, class, field, "I")?.i()
}
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
crate::jcall::call_static_method(
env,
"androidx/core/app/NotificationManagerCompat",
"from",
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
&[JValue::Object(context)],
)?
.l()
}
fn create_channel(
env: &mut Env,
manager: &JObject,
id: &str,
name: &str,
importance: i32,
) -> Result<()> {
let id_j = crate::jcall::jstr_obj(env, id)?;
let builder = crate::jcall::new_object(
env,
"androidx/core/app/NotificationChannelCompat$Builder",
"(Ljava/lang/String;I)V",
&[JValue::Object(&id_j), JValue::Int(importance)],
)?;
let name_j = crate::jcall::jstr_obj(env, name)?;
crate::jcall::call_method(
env,
&builder,
"setName",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
&[JValue::Object(&name_j)],
)?;
let channel = crate::jcall::call_method(
env,
&builder,
"build",
"()Landroidx/core/app/NotificationChannelCompat;",
&[],
)?
.l()?;
crate::jcall::call_method(
env,
manager,
"createNotificationChannel",
"(Landroidx/core/app/NotificationChannelCompat;)V",
&[JValue::Object(&channel)],
)?;
Ok(())
}
/// Two channels, because they are two different things to be told -- see
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
/// importances here are copied from it exactly, since a phone that has
/// seen both apps should not learn two different vocabularies for the
/// same fact.
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
let manager = notification_manager(env, context)?;
let default = static_int(
env,
"androidx/core/app/NotificationManagerCompat",
"IMPORTANCE_DEFAULT",
)?;
let min = static_int(
env,
"androidx/core/app/NotificationManagerCompat",
"IMPORTANCE_MIN",
)?;
create_channel(
env,
&manager,
ALERT_CHANNEL,
"Sessions needing attention",
default,
)?;
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
Ok(())
}
fn new_intent_for<'l>(
env: &mut Env<'l>,
context: &JObject,
class_name: &str,
) -> Result<JObject<'l>> {
let target_class = crate::jcall::find_class(env, class_name)?;
crate::jcall::new_object(
env,
"android/content/Intent",
"(Landroid/content/Context;Ljava/lang/Class;)V",
&[JValue::Object(context), JValue::Object(&target_class)],
)
}
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
/// `sessionIntent`, including building the URI through `Uri.Builder`
/// rather than string concatenation, for the same reason: an id needing
/// escaping must survive the round trip.
fn session_intent<'l>(
env: &mut Env<'l>,
context: &JObject,
session_id: &str,
) -> Result<JObject<'l>> {
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
crate::jcall::call_method(
env,
&intent,
"setAction",
"(Ljava/lang/String;)Landroid/content/Intent;",
&[JValue::Object(&action_view)],
)?;
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
crate::jcall::call_method(
env,
&builder,
"scheme",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&scheme)],
)?;
let authority = crate::jcall::jstr_obj(env, "session")?;
crate::jcall::call_method(
env,
&builder,
"authority",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&authority)],
)?;
let path = crate::jcall::jstr_obj(env, session_id)?;
crate::jcall::call_method(
env,
&builder,
"appendPath",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&path)],
)?;
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
crate::jcall::call_method(
env,
&intent,
"setData",
"(Landroid/net/Uri;)Landroid/content/Intent;",
&[JValue::Object(&uri)],
)?;
Ok(intent)
}
fn pending_activity<'l>(
env: &mut Env<'l>,
context: &JObject,
intent: &JObject,
) -> Result<JObject<'l>> {
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
crate::jcall::call_static_method(
env,
"android/app/PendingIntent",
"getActivity",
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
&[
JValue::Object(context),
JValue::Int(0),
JValue::Object(intent),
JValue::Int(update_current | immutable),
],
)?
.l()
}
fn builder_call<'l>(
env: &mut Env<'l>,
builder: &JObject<'l>,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<()> {
crate::jcall::call_method(env, builder, method, sig, args)?;
Ok(())
}
/// The type Android 14+ requires a foreground service to declare, and
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
fn foreground_type(env: &mut Env) -> Result<i32> {
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
if sdk >= upside_down_cake {
static_int(
env,
"android/content/pm/ServiceInfo",
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
)
} else {
Ok(0)
}
}
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
let builder = crate::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
builder_call(
env,
&builder,
"setSmallIcon",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(icon)],
)?;
builder_call(
env,
&builder,
"setOngoing",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
builder_call(
env,
&builder,
"setPriority",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(priority_min)],
)?;
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
}
/// Starts the service if there is a server to connect to, and stops it
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
let service_intent =
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
if settings::load(env, context)?.is_none() {
crate::jcall::call_method(
env,
context,
"stopService",
"(Landroid/content/Intent;)Z",
&[JValue::Object(&service_intent)],
)?;
return Ok(());
}
create_channels(env, context)?;
crate::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"startForegroundService",
"(Landroid/content/Context;Landroid/content/Intent;)V",
&[JValue::Object(context), JValue::Object(&service_intent)],
)?;
Ok(())
}
/// The `Service.onStartCommand` body -- loads settings, starts the
/// foreground notification, and spawns the follow-loop thread. Answers the
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
/// framework rather than hardcoded so a wrong guess at their values cannot
/// silently pick the other behaviour.
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
match try_start(env, &service) {
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
Ok(false) => {
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
Err(e) => {
log_error(env, "onStartCommand", &e);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
}
}
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
let Some(settings) = settings::load(env, service)? else {
return Ok(false);
};
let ca = settings::load_pinned_ca(env)?;
let notification = ongoing_notification(env, service)?;
let fg_type = foreground_type(env)?;
crate::jcall::call_static_method(
env,
"androidx/core/app/ServiceCompat",
"startForeground",
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
&[
JValue::Object(service),
JValue::Int(ONGOING_ID),
JValue::Object(&notification),
JValue::Int(fg_type),
],
)?;
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
// the first is still going -- the ordinary case for this service,
// since `sync()` is called from more than one place -- must not open
// a second connection.
if RUNNING.swap(true, Ordering::SeqCst) {
return Ok(true);
}
let vm = env.get_java_vm()?;
let context = env.new_global_ref(service)?;
STOPPING.store(false, Ordering::SeqCst);
std::thread::Builder::new()
.name("ai-app-notifications".to_string())
.spawn(move || {
// Requests a *permanent* attachment (detached only when this thread
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
// this is the long-lived follow loop, not a one-shot callback.
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
follow_loop(env, &context, settings, &ca);
Ok(())
});
})
.ok();
Ok(true)
}
/// Follows the backend's notification stream, reconnecting until stopped
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
/// ordinary case, so it retries quietly and forever; nothing is shown when
/// it cannot connect, for the same reason as the Kotlin original: a
/// notification saying "I could not tell you whether anything happened" is
/// noise about a condition nobody can act on.
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
while !STOPPING.load(Ordering::SeqCst) {
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
let _ = follow_notifications(&transport, |notification| {
if let Err(e) = show(env, context, &notification) {
log_error(env, "show", &e);
}
!STOPPING.load(Ordering::SeqCst)
});
}
if STOPPING.load(Ordering::SeqCst) {
return;
}
std::thread::sleep(RECONNECT_DELAY);
}
}
/// One notification per session, replacing that session's previous one --
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
/// branches this module's doc comment explains.
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
let manager = notification_manager(env, context)?;
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
let allowed = if sdk < tiramisu {
true
} else {
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
let granted = static_int(
env,
"android/content/pm/PackageManager",
"PERMISSION_GRANTED",
)?;
let result = crate::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"checkSelfPermission",
"(Landroid/content/Context;Ljava/lang/String;)I",
&[JValue::Object(context), JValue::Object(&permission)],
)?
.i()?;
result == granted
};
let enabled =
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
if !allowed || !enabled {
return Ok(());
}
let intent = session_intent(env, context, &notification.session_id)?;
let pending = pending_activity(env, context, &intent)?;
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
let builder = crate::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::jcall::jstr_obj(env, &notification.title)?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
builder_call(
env,
&builder,
"setContentText",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&text)],
)?;
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
builder_call(
env,
&builder,
"setSmallIcon",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(icon)],
)?;
builder_call(
env,
&builder,
"setContentIntent",
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&pending)],
)?;
builder_call(
env,
&builder,
"setAutoCancel",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let when = (notification.at * 1000.0) as i64;
builder_call(
env,
&builder,
"setWhen",
"(J)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Long(when)],
)?;
builder_call(
env,
&builder,
"setShowWhen",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let built =
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
.l()?;
let tag = crate::jcall::jstr_obj(env, &notification.session_id)?;
crate::jcall::call_method(
env,
&manager,
"notify",
"(Ljava/lang/String;ILandroid/app/Notification;)V",
&[
JValue::Object(&tag),
JValue::Int(ALERT_ID),
JValue::Object(&built),
],
)?;
Ok(())
}
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
/// the gap this module's `STOPPING` doc explains.
pub fn on_destroy() {
STOPPING.store(true, Ordering::SeqCst);
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
// comment): the old thread may still be inside a blocked read when a
// new `onStartCommand` follows immediately, which would spawn a
// second one before the first has actually stopped. Narrower than not
// resetting at all -- a service destroyed and never restarted would
// otherwise wedge `RUNNING` true forever -- and no worse than the
// known gap already accepted above.
RUNNING.store(false, Ordering::SeqCst);
}
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
let message = format!("android-shell: {where_}: {error}");
let _ = (|| -> Result<()> {
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
let msg = crate::jcall::jstr_obj(env, &message)?;
crate::jcall::call_static_method(
env,
"android/util/Log",
"e",
"(Ljava/lang/String;Ljava/lang/String;)I",
&[JValue::Object(&tag), JValue::Object(&msg)],
)?;
Ok(())
})();
}
+141
View File
@@ -0,0 +1,141 @@
//! Enrollment: where the backend is, and the Keystore-sealed token to
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
//! class the production app already uses (see `ServerConfig.kt`), through
//! JNI, for two reasons: that code is shared with Dev Updater and already
//! tested, and the sealed value on a real phone is keyed to the exact
//! Keystore alias that class already uses -- reimplementing the crypto
//! here would either duplicate it or invalidate an existing enrollment.
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
/// Where the backend is and how to authenticate to it -- the Rust twin of
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
/// rather than kept as a live JNI reference, so it can cross a thread
/// boundary (a `JObject` is tied to one `Env`/thread).
#[derive(Debug, Clone)]
pub struct ServerSettings {
pub host: String,
pub port: i32,
pub token: String,
}
impl ServerSettings {
pub fn base_url(&self) -> String {
format!("https://{}:{}", self.host, self.port)
}
}
/// This experiment's own scheme and Keystore alias -- distinct from the
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
/// installed side by side on the same development device without
/// colliding over which one a scanned QR or a deep link resolves to. See
/// RUST.md's E3 entry for why they are not the same value.
pub(crate) const SCHEME: &str = "aiappshell";
const KEY_ALIAS: &str = "aiapp-shell-token-key";
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
crate::jcall::new_object(
env,
STORE_CLASS,
"(Ljava/lang/String;Ljava/lang/String;)V",
&[JValue::Object(&scheme), JValue::Object(&alias)],
)
}
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
let host = get_string(env, settings_obj, "getHost")?;
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
let token = get_string(env, settings_obj, "getToken")?;
Ok(ServerSettings { host, port, token })
}
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
jstr.try_to_string(env)
}
/// The stored enrollment, or `None` when there is not one -- mirrors
/// `ServerConfig.kt`'s `loadServerSettings`.
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::jcall::call_method(
env,
&store,
"load",
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
&[JValue::Object(context)],
)?
.l()?;
if settings_obj.is_null() {
return Ok(None);
}
Ok(Some(read_settings(env, &settings_obj)?))
}
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
let store = new_store(env)?;
let host = crate::jcall::jstr_obj(env, &settings.host)?;
let token = crate::jcall::jstr_obj(env, &settings.token)?;
let settings_obj = crate::jcall::new_object(
env,
SETTINGS_CLASS,
"(Ljava/lang/String;ILjava/lang/String;)V",
&[
JValue::Object(&host),
JValue::Int(settings.port),
JValue::Object(&token),
],
)?;
crate::jcall::call_method(
env,
&store,
"save",
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
&[JValue::Object(context), JValue::Object(&settings_obj)],
)?;
Ok(())
}
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
/// query-parameter rules rather than re-deriving them here.
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::jcall::call_method(
env,
&store,
"parseEnrollmentUri",
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
&[JValue::Object(uri)],
)?
.l()?;
if settings_obj.is_null() {
return Ok(None);
}
Ok(Some(read_settings(env, &settings_obj)?))
}
/// The CA this build pins, generated at build time the same way
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
/// but into a plain Java constant, since this module has no Kotlin of its
/// own to generate into.
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
let value = crate::jcall::get_static_field(
env,
"com/example/aiapp/shell/PinnedCa",
"PINNED_CA_PEM",
"Ljava/lang/String;",
)?
.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
Ok(jstr.try_to_string(env)?.into_bytes())
}
+183
View File
@@ -0,0 +1,183 @@
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
//!
//! **Scope cut, recorded rather than silent**: only shared *text*
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
//! ported here, because `client-core`'s `ApiClient` does not have the
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
//! `content://` stream and no text falls through to a toast saying so,
//! rather than silently doing nothing. Closing this gap is the same
//! `client-core` work whichever caller needs it next.
//!
//! **Which session a share lands in** is also a placeholder: with no
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
//! to whichever session has the latest `last_activity` -- the one most
//! likely to be what somebody meant. Worth revisiting once a real screen
//! exists to ask instead of guessing.
use client_core::api::{ApiClient, UreqTransport};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
use crate::notify;
use crate::settings;
const ACTION_SEND: &str = "android.intent.action.SEND";
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
const ACTION_VIEW: &str = "android.intent.action.VIEW";
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
if value.is_null() {
return Ok(None);
}
let jstr: JString = env.cast_local::<JString>(value)?;
Ok(Some(jstr.try_to_string(env)?))
}
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
let message = crate::jcall::jstr_obj(env, message)?;
crate::jcall::call_static_method(
env,
"com/example/aiapp/shell/MainActivity",
"toast",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&message)],
)?;
Ok(())
}
/// The one place an incoming intent is sorted into what it means -- mirrors
/// `MainActivity.kt`'s `handleIntent`.
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let action = get_string_method(env, intent, "getAction")?;
if matches!(
action.as_deref(),
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
) {
return handle_share(env, activity, intent);
}
if action.as_deref() != Some(ACTION_VIEW) {
return Ok(());
}
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
if uri.is_null() {
return Ok(());
}
let scheme = get_string_method(env, &uri, "getScheme")?;
if scheme.as_deref() != Some(settings::SCHEME) {
return Ok(());
}
match get_string_method(env, &uri, "getHost")?.as_deref() {
Some("session") => handle_session_open(env, activity, &uri),
Some("enroll") => handle_enrollment(env, activity, &uri),
_ => Ok(()),
}
}
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
return Ok(());
};
// There is no session screen yet (E4's job); the toast is this
// experiment's stand-in proof that the tap was routed to the right
// session id.
toast(env, activity, &format!("Opened session {session_id}"))
}
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
match settings::parse_enrollment_uri(env, uri)? {
Some(parsed) => {
settings::save(env, activity, &parsed)?;
notify::sync(env, activity)?;
toast(
env,
activity,
&format!("Enrolled with {}", parsed.base_url()),
)
}
None => toast(env, activity, "Not a valid enrollment code"),
}
}
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
/// what happens to it, minus attachments per this module's doc comment.
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
let text = crate::jcall::call_method(
env,
intent,
"getStringExtra",
"(Ljava/lang/String;)Ljava/lang/String;",
&[JValue::Object(&extra_text)],
)?
.l()?;
let text = if text.is_null() {
None
} else {
let jstr: JString = env.cast_local::<JString>(text)?;
Some(jstr.try_to_string(env)?)
};
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
return toast(
env,
activity,
"Nothing to share -- only shared text is supported so far",
);
};
// Network I/O must not run on the calling thread: `handle_intent` is
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
// blocking socket read there is a `NetworkOnMainThreadException`. So
// the actual send happens on a JNI-attached background thread, the
// same shape `notify::try_start`'s follow loop uses; `toast` from that
// thread is safe because `MainActivity.toast` itself hops back to the
// main looper (see that method).
let vm = env.get_java_vm()?;
let activity_ref = env.new_global_ref(activity)?;
std::thread::spawn(move || {
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
share_in_background(env, &activity_ref, text);
Ok(())
});
});
Ok(())
}
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
let outcome = attach_to_a_session(env, activity, &text);
let message = match outcome {
Ok(title) => format!("Shared into \"{title}\""),
Err(message) => message,
};
let _ = toast(env, activity, &message);
}
fn attach_to_a_session(
env: &mut Env,
activity: &JObject,
text: &str,
) -> std::result::Result<String, String> {
let settings = settings::load(env, activity)
.map_err(|e| e.to_string())?
.ok_or_else(|| "Not enrolled yet".to_string())?;
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
.map_err(|e| e.to_string())?;
let client = ApiClient::new(transport);
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
let target = sessions
.into_iter()
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
.ok_or_else(|| "No session to share into".to_string())?;
client
.send_message(&target.id, text, &[])
.map_err(|e| e.to_string())?;
Ok(target.title)
}
+39
View File
@@ -102,6 +102,16 @@ android {
targetSdk = 37
versionCode = 1
versionName = "1.0"
// Read by MainActivity to decide, at startup, whether this is the P0 benchmark build
// (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except
// the `bench` build type below, which overrides it.
buildConfigField("boolean", "FIXTURE_MODE", "false")
}
buildFeatures {
// Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields.
buildConfig = true
// Only for the bench build type's resValue("string", "app_name", ...) below.
resValues = true
}
packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
@@ -131,6 +141,35 @@ android {
isMinifyEnabled = false
if (keystore != null) signingConfig = signingConfigs.getByName("release")
}
// P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release
// optimisations so a frame time measured here means what release means everywhere else in
// this project, its own application id so it installs beside a real enrollment rather than
// replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session
// instead of asking to be enrolled. Signed with the same key as release -- it never talks
// to a real backend, so there is no CA of its own to mismatch, and a second keystore would
// be one more secret to keep off this machine's shared mount for no benefit.
create("bench") {
initWith(getByName("release"))
// :link (wg-app-link) has no "bench" build type of its own -- it is a library shared
// with dev-updater and has no reason to know this project invented one -- so this says
// which of its build types to link against instead.
matchingFallbacks += listOf("release")
applicationIdSuffix = ".bench"
// "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents,
// Settings): this resValue overrides res/values/strings.xml's app_name for this
// build type alone, and AndroidManifest.xml's android:label reads @string/app_name
// rather than a literal so a build type can override it without touching the
// manifest.
resValue("string", "app_name", "AI Sessions bench")
buildConfigField("boolean", "FIXTURE_MODE", "true")
if (keystore != null) signingConfig = signingConfigs.getByName("release")
}
}
sourceSets {
// The fixture both bench builds (this one and iris's) open with; see
// app/bench-fixture/README.md. Read directly from its own directory rather than copied
// into androidApp/src -- one file to keep in sync with the generator, not two.
getByName("bench").assets.directories.add("../bench-fixture/assets")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
+1 -1
View File
@@ -25,7 +25,7 @@
the fix is a judgement about how this app should look. Drop this
suppression when a real icon lands. -->
<application
android:label="AI Sessions"
android:label="@string/app_name"
android:allowBackup="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
tools:ignore="MissingApplicationIcon">
@@ -13,9 +13,8 @@ import androidx.compose.ui.text.style.TextDecoration
*
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
* Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from
* somewhere else, but the two are not one table and must not become one -- adding a syntax role to
* this list would silently move `ls`'s directory blue.
* Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one
* table -- adding a syntax role to this list would silently move `ls`'s directory blue.
*/
data class AnsiPalette(
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
@@ -30,19 +29,18 @@ data class AnsiPalette(
* What a tool printed, with its terminal styling applied and everything else taken out.
*
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
* verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of
* every green word. Stripping them all would be the other half-answer -- colour is often the whole
* of what a diff, a test run or a linter is saying.
* verbatim those are line noise in the middle of the thing being read. Stripping them all would be
* the other half-answer -- colour is often the whole of what a diff or a test run is saying.
*
* So the sequences that decide how text *looks* become spans, and every other one is dropped.
* Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript
* is a scrolling document, and "go to column 40" has no meaning here that is better than nothing.
* So the sequences that decide how text *looks* become spans, and every other one is dropped rather
* than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no
* meaning in a scrolling document.
*
* A carriage return is honoured the way a terminal honours it: what was written since the last line
* break is thrown away and the line starts again. That is what makes a progress bar show its final
* state rather than every state it passed through, which was tens of lines run together.
* state rather than every state it passed through.
*
* Not a composable, and the palette is a parameter: this can then be remembered against the text it
* Not a composable, and the palette is a parameter, so this can be remembered against the text it
* parsed rather than re-run on every recomposition of the card holding it.
*/
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
@@ -71,10 +69,9 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
if (final == 'm') sgr = sgr.apply(params, palette)
}
}
// A bare carriage return rewrites the line. One before a newline is the other half
// of a Windows line ending: it rewrites nothing, and it is dropped rather than kept,
// since that pair is one line break and the return itself would draw as a stray
// control character.
// A bare carriage return rewrites the line. One before a newline is the other half of a
// Windows line ending: it rewrites nothing, and it is dropped rather than kept, since
// that pair is one line break.
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
flush()
dropLine(runs)
@@ -82,8 +79,8 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
}
c == '\r' -> at++
// Everything printable, plus the two control characters that are layout rather than
// terminal commands. A stray bell or backspace goes for the same reason a cursor
// move does.
// terminal commands. A stray bell or backspace goes for the same reason a cursor move
// does.
c >= ' ' || c == '\n' || c == '\t' -> {
plain.append(c)
at++
@@ -129,9 +126,8 @@ private const val BELL = '\u0007'
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
*
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did
* not recognise would otherwise have its body printed as ordinary text, which is worse than the
* escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes
* (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones.
* not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI
* (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones.
*/
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
val next = text.getOrNull(at + 1) ?: return at + 1
@@ -140,9 +136,9 @@ private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Un
var end = at + 2
while (end < text.length && text[end] !in CSI_FINAL) end++
if (end >= text.length) {
// Cut off mid-sequence, which is what a stream that has not finished arriving
// looks like: drop the fragment rather than printing it, and the whole sequence
// arrives with the next delta.
// Cut off mid-sequence, which is what a stream that has not finished arriving looks
// like: drop the fragment rather than printing it, and the whole sequence arrives
// with the next delta.
text.length
} else {
onCsi(text.substring(at + 2, end), text[end])
@@ -6,26 +6,34 @@ import java.net.URL
import org.json.JSONArray
import org.json.JSONObject
// The REST half of the backend's surface (see server/src/routes.rs for the
// table); the SSE half is EventStream.kt. All blocking network calls --
// invoke from a background dispatcher. Each throws ApiException on failure,
// carrying the server's own explanation where it sent one, since those
// messages are written to be read on this screen.
// The REST half of the backend's surface (see server/src/routes.rs for the table); the SSE half is
// EventStream.kt. All blocking network calls -- invoke from a background dispatcher. Each throws
// ApiException carrying the server's own explanation where it sent one, since those messages are
// written to be read on this screen.
// Shared with EventStream.kt, which connects the same way but then reads
// without a deadline.
// Shared with EventStream.kt, which connects the same way but then reads without a deadline.
const val CONNECT_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 5000
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* A request that did not produce what it asked for, carrying the server's own wording where it sent
* some.
*
* [status] is the HTTP status where there was a response at all, and null where the server was
* never reached. Callers that need it need it because the *same* failure is two different things to
* do: a 409 from a write is "somebody else changed this, here are three ways out". Nothing should
* branch on it to decide what to *say* -- the message is what says that.
*/
class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) :
Exception(message, cause)
/**
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
* failure translation every call needs. [readBody] gets the connected, already-status-checked
* connection to read from.
* connection.
*
* @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through
* here -- an event stream has no bounded read time (see EventStream.kt).
* here -- an event stream has no bounded read time.
*/
fun <T> requestFromServer(
settings: ServerSettings,
@@ -33,9 +41,9 @@ fun <T> requestFromServer(
method: String = "GET",
jsonBody: String? = null,
/**
* A request body written as it is produced -- the upload path. Content type, and a writer
* handed the connection's stream. Sent chunked, since what a writer will produce is not known
* up front and the point is that a file never sits whole in memory on this side.
* A request body written as it is produced -- the upload path. Sent chunked, since what a
* writer will produce is not known up front and the point is that a file never sits whole in
* memory.
*/
streamBody: Pair<String, (java.io.OutputStream) -> Unit>? = null,
readTimeoutMs: Int = READ_TIMEOUT_MS,
@@ -68,38 +76,36 @@ fun <T> requestFromServer(
detail.isNullOrEmpty() ->
"Server returned HTTP ${connection.responseCode} for $path"
else -> detail
}
},
status = connection.responseCode,
)
}
return readBody(connection)
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
// Surfacing the real exception (rather than one canned message for
// every failure mode) is what lets this be diagnosed on a device
// with no logcat access.
// Surfacing the real exception rather than one canned message for every failure mode is
// what lets this be diagnosed on a device with no logcat access.
throw ApiException(
"Couldn't reach the server at ${settings.baseUrl} " +
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
"this device able to reach that address (WireGuard up)?",
e,
cause = e,
)
} catch (e: Exception) {
throw ApiException(
"Reached ${settings.baseUrl}$path but couldn't read its response " +
"(${e::class.simpleName}: ${e.message})",
e,
cause = e,
)
} finally {
connection.disconnect()
}
}
/** The response body as one JSON object. */
private fun HttpURLConnection.jsonObject(): JSONObject =
JSONObject(inputStream.bufferedReader().readText())
/** The response body as a JSON array of objects, each mapped through [parse]. */
private fun <T> HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List<T> =
JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse)
@@ -108,21 +114,16 @@ private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) }
/** Percent-encodes a value going into a query string. */
private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name())
// One row of GET /sessions. A session names the machine it runs on and
// which of that machine's providers it runs.
// One row of GET /sessions. A session names the machine it runs on and which of that machine's
// providers it runs.
data class SessionSummary(
val id: String,
/**
* Id of the machine this session runs on. Only ever used to *address* that machine -- to pick
* this session's row out of the per-machine usage snapshots for the header's five-hour bar.
*
* It was deliberately left out until 2026-08-29, on the grounds that nothing here addressed a
* setup and holding both the id and the name invited showing the wrong one, which had already
* happened once. Something addresses one now, so the reason lapsed rather than being overruled.
* The guard that replaces it is the rule below: never show this.
* this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a
* reader sees, and holding both invites showing the wrong one.
*/
val setup: String,
/** The machine's current label. This is the one to display; [setup] is never shown. */
@@ -135,13 +136,26 @@ data class SessionSummary(
* provider's kind rather than here from its name.
*
* What it licenses is narrow, and the delete dialog is worded to match: the driver keeps its
* own record of the conversation somewhere this app's delete does not reach. It is not a
* promise that the file is still there, and re-importing is not a restore -- this app's
* transcript holds things that record does not.
* own record somewhere this app's delete does not reach. It is not a promise that the file is
* still there, and re-importing is not a restore.
*/
val keepsOwnTranscript: Boolean,
/** How much the session asks before acting; null when it was never set. */
val permissionMode: String?,
/**
* How hard the model thinks, or null for the CLI's own default.
*
* Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is
* reported rather than assumed for the same reason [permissionMode] is.
*/
val effort: String?,
/**
* Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one.
*
* Asked of the server rather than worked out from the provider's name, because this is a
* property of the driver's *kind* and the phone only has the name.
*/
val takesEffort: Boolean,
/**
* Whether this continues a session the machine already had, which changes what deleting means.
*/
@@ -150,39 +164,73 @@ data class SessionSummary(
* Whether this session announces itself when it wants attention.
*
* Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws
* itself from a default is one you can turn off while believing you are reading it. Defaults to
* on when a backend is too old to say, which matches what that backend actually does.
* itself from a default is one you can turn off while believing you are reading it.
*/
val notify: Boolean,
/**
* Whether this session sends itself a message once its account's usage limit lifts, and what
* that message says.
*
* The message is what the server would actually send, with its own default already filled in,
* so the field shows the words rather than an empty box standing for them.
*/
val autoResume: Boolean,
val autoResumeMessage: String,
/**
* When the server next intends to check whether the limit has lifted, in epoch seconds, or null
* when nothing is waiting.
*
* A time to *ask*, not a time to resume: the server checks the meter at that moment and waits
* again if the limit is still on. Worded that way wherever it is shown, because a promise this
* app cannot keep is worse than no time at all.
*/
val resumeAt: Double?,
/**
* The directory the session works in, or null where it was never given one.
*
* Null is not "the home directory": it is the session never having been told, and what the
* process then starts in belongs to whatever launches it. Shown as unset rather than filled in
* with a guess, so a reader changing it is choosing rather than confirming.
* Null is not "the home directory": it is the session never having been told. Shown as unset
* rather than filled in with a guess, so a reader changing it is choosing rather than
* confirming.
*/
val cwd: String?,
/**
* How much context this session is holding, as the server last measured it -- see
* `SessionEvent.UsageDelta`.
* How much context this session is holding, as the server last measured it.
*
* Null where nothing has been measured: a session that has not run a turn, a provider that does
* not report usage, or a clear nobody has run a turn since. That is not zero, and the status
* row says so in words rather than drawing an empty context for a conversation that may be
* nearly full.
* full.
*/
val contextTokens: Long?,
/**
* The longest edge an image should have when it reaches this session, or null where the
* provider has no limit.
*
* Null and "a big number" are different answers, and only the first stays true: a provider that
* does not care about size should not be given a threshold this app invented. Decided by the
* server because that is where a provider's kind is known -- see `uploadPickedImage`.
* Null and "a big number" are different answers, and only the first stays true. Decided by the
* server because that is where a provider's kind is known.
*/
val maxImageEdge: Int?,
/**
* Which of `GET /usage`'s snapshots is about this session, and null where nothing meters it.
*
* The rate-limit bar answers a question about an *account*, and what decides which account --
* if any -- is the provider this session runs, not the machine it runs on. Pairing by machine
* alone drew the Claude CLI's five-hour window under every echo session on a machine that also
* has the CLI: a quota that session cannot spend and could never run down. Decided by the
* server for the same reason [maxImageEdge] is -- it is a fact about the provider's kind, and
* this app has only its name.
*/
val usageProvider: String?,
val status: String,
val lastActivity: Double,
/**
* How many subagents this session has, however their own status now reads.
*
* A directory listing on the server rather than a status read per subagent, so the list stays
* cheap; the per-subagent state is only fetched when the card is expanded. Zero on a server
* that predates subagents, so this app still opens against one.
*/
val subagents: Int,
)
private fun parseSession(session: JSONObject) =
@@ -195,14 +243,24 @@ private fun parseSession(session: JSONObject) =
title = session.getString("title"),
model = session.optString("model").ifEmpty { null },
permissionMode = session.optString("permissionMode").ifEmpty { null },
effort = session.optString("effort").ifEmpty { null },
takesEffort = session.optBoolean("takesEffort", false),
imported = session.optBoolean("imported", false),
notify = session.optBoolean("notify", true),
autoResume = session.optBoolean("autoResume", false),
// The server sends its own default rather than nothing, so an empty answer means an older
// server -- and this app's word for it is the same word.
autoResumeMessage =
session.optString("autoResumeMessage").ifEmpty { DEFAULT_RESUME_MESSAGE },
resumeAt = if (session.has("resumeAt")) session.getDouble("resumeAt") else null,
cwd = session.optString("cwd").ifEmpty { null },
contextTokens =
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
usageProvider = session.optString("usageProvider").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
subagents = session.optInt("subagents", 0),
)
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
@@ -213,18 +271,45 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
*
* For screens whose controls are *set to* something rather than merely showing it. A screen opened
* from a list row carries the row the list last fetched, which is a snapshot: fine for a title,
* wrong for a switch, since a switch drawn from a stale row shows a position that may have been
* changed since -- here or on another device -- and nothing on screen says which.
* wrong for a switch, since a stale row shows a position that may have been changed since.
*/
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
// What the server offers, so the spawn screen has no hardcoded lists: a
// setup added to the server's config.ron appears here with no app rebuild.
/**
* One row of `GET /sessions/{id}/subagents`, oldest first.
*
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
* running, and the list says so rather than reporting a state that cannot hold.
*/
data class SubagentSummary(
val id: String,
val title: String,
val status: String,
val created: Double,
val lastActivity: Double,
)
fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
requestFromServer(settings, "/sessions/$sessionId/subagents") {
it.jsonObjects { row ->
SubagentSummary(
id = row.getString("id"),
title = row.getString("title"),
status = row.getString("status"),
created = row.getDouble("created"),
lastActivity = row.getDouble("lastActivity"),
)
}
}
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
// config.ron appears here with no app rebuild.
//
// One list rather than two. A provider only exists on a machine that has
// it installed, so offering machines and providers as independent choices
// would offer pairs that cannot work.
// One list rather than two. A provider only exists on a machine that has it installed, so offering
// machines and providers as independent choices would offer pairs that cannot work.
data class Provider(val name: String, val kind: String, val models: List<String>)
/**
@@ -263,7 +348,7 @@ fun fetchSetups(settings: ServerSettings): List<Setup> =
* A Claude Code session already on a machine, which can be continued here.
*
* Identified by [id] and never by a path. The server resolves which file that is, so this app has
* no way to ask it to read one -- the same rule that keeps a provider's command out of this client.
* no way to ask it to read one.
*/
data class Importable(
val id: String,
@@ -272,19 +357,15 @@ data class Importable(
val modified: Double,
val lines: Int,
/**
* Size of the session file in bytes.
*
* Worth a place on the row because it is the only thing there that predicts what continuing the
* session costs, and the line count does not: these transcripts embed screenshots as base64, so
* a single line can be a megabyte.
* Size of the session file in bytes. Worth a place on the row because it is the only thing
* there that predicts what continuing the session costs, and the line count does not: these
* transcripts embed screenshots as base64, so a single line can be a megabyte.
*/
val bytes: Long,
/**
* Tokens the model was holding at the last turn, or null if no turn has recorded any.
*
* The number that predicts what continuing this session costs. It disagrees with [bytes] in the
* direction that matters: most of a large transcript is usually history from before a
* compaction, which the model is no longer given.
* Tokens the model was holding at the last turn, or null if no turn has recorded any. It
* disagrees with [bytes] in the direction that matters: most of a large transcript is usually
* history from before a compaction, which the model is no longer given.
*/
val contextTokens: Long?,
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */
@@ -293,17 +374,15 @@ data class Importable(
* Whether a Claude Code is running this session right now.
*
* "unknown" is a third answer and not a synonym for "no": the machine may keep no record of
* what is running, and a session that cannot be checked is not a session that is free. The
* server refuses an import of a "yes"; the row says so before you press it.
* what is running. The server refuses an import of a "yes"; the row says so before you press
* it.
*/
val inUse: String,
/**
* What this server is doing to the session right now -- "importing" or "deleting" -- or null
* when nothing is.
* What this server is doing to the session right now -- "importing" or "deleting" -- or null.
*
* The server's answer rather than the phone's, because the work outlives the screen that asked
* for it: leaving the import list and coming back has to show what is still running, and a
* phone that was asleep or out of range never saw the events that said so.
* for it: a phone that was asleep never saw the events that said so.
*/
val pending: String?,
/**
@@ -316,7 +395,7 @@ data class Importable(
/**
* One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing.
*
* [operation] is only set by a start, and [message] only by a failure -- the three states are every
* [operation] is only set by a start and [message] only by a failure -- the three states are every
* way an operation can be, and each carries exactly what that state knows.
*/
data class ImportableChange(
@@ -336,20 +415,18 @@ fun parseImportableChange(payload: String): ImportableChange? =
message = frame.optString("message").takeIf { it.isNotEmpty() },
)
} catch (_: org.json.JSONException) {
// A frame this build does not understand is not a reason to drop the stream: the listing
// is the truth and will say what happened whatever this missed.
// A frame this build does not understand is not a reason to drop the stream: the listing is
// the truth and will say what happened whatever this missed.
null
}
/**
* What a machine has that could be continued.
*
* The slowest call this app makes, and it was the only expensive one left on the 5 second default
* which is how it came to time out against a server that was answering perfectly well. Listing
* means reading every transcript Claude Code has ever written: about four seconds against a
* gigabyte of them before the tunnel adds anything, and that figure grows with every session
* anybody has. A timeout is for a server that has stopped answering, so it is set well clear of how
* long the work takes rather than just above it.
* The slowest call this app makes, and it was the only expensive one left on the 5 second default
* -- which is how it came to time out against a server answering perfectly well. Listing means
* reading every transcript Claude Code has ever written: about four seconds against a gigabyte of
* them before the tunnel adds anything. A timeout is for a server that has stopped answering.
*/
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
@@ -361,13 +438,11 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
modified = session.optDouble("modified", 0.0),
lines = session.optInt("lines", 0),
bytes = session.optLong("bytes", 0L),
// Absent means nothing has been measured -- which is not a context of zero, so
// it stays null and the row simply does not claim a figure.
// Absent means nothing has been measured, which is not a context of zero.
contextTokens =
if (session.isNull("contextTokens")) null
else session.optLong("contextTokens").takeIf { it > 0L },
// Absent means an older backend that cannot answer, which is exactly what
// "unknown" says -- so the default is the honest one rather than "no".
// Absent means an older backend that cannot answer, which is what "unknown" says.
inUse = session.optString("inUse", "unknown"),
named = session.optBoolean("named", false),
pending = session.optString("pending").takeIf { it.isNotEmpty() },
@@ -390,6 +465,12 @@ data class SshDetails(
* Where files attached from here land on that machine; null for the session's own directory.
*/
val attachmentsDir: String? = null,
/**
* Where that machine keeps its GGUF models; null for the same place the backend keeps its own
* (`~/.local/share/ai-app/models`, read on that machine). A llama.cpp session serves the file
* from the machine it runs on, so this is where its models are looked for and listed.
*/
val modelsDir: String? = null,
)
private fun SshDetails.toJson() =
@@ -397,6 +478,7 @@ private fun SshDetails.toJson() =
if (port != null) put("port", port)
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir)
}
/** What a machine turns out to have, without saving anything. */
@@ -465,6 +547,8 @@ fun spawnSession(
model: String? = null,
cwd: String? = null,
permissionMode: String? = null,
/** Null for whatever the server's default is; see [fetchDefaultEffort]. */
effort: String? = null,
params: Map<String, String> = emptyMap(),
/** Continue this Claude Code session instead of starting an empty one. */
import: String? = null,
@@ -482,6 +566,7 @@ fun spawnSession(
if (!model.isNullOrBlank()) put("model", model)
if (!cwd.isNullOrBlank()) put("cwd", cwd)
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
if (!effort.isNullOrBlank()) put("effort", effort)
if (!import.isNullOrBlank()) put("import", import)
if (params.isNotEmpty()) {
put("params", JSONObject(params.toMap<String, Any>()))
@@ -516,8 +601,7 @@ fun sendMessage(
*
* Throws rather than returning an outcome, because both ways of failing are things the reader has
* to be told: 409 means the session was already given it, and 404 means nothing is waiting under
* that id. The bubble disappearing is the success case and it arrives on the event stream, not from
* here -- every device drops it, not only the one that tapped.
* that id. The bubble disappearing arrives on the event stream, so every device drops it.
*/
fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) {
requestFromServer(
@@ -531,13 +615,11 @@ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: Strin
/**
* Moves a session to a different working directory.
*
* The server checks the directory is there on that machine and refuses if it is not -- a mistyped
* path accepted here would surface much later, as a session that would not start, with nothing
* pointing at the typo.
* The server checks the directory is there and refuses if it is not -- a mistyped path accepted
* here would surface much later, as a session that would not start.
*
* Its process is **stopped**, because a working directory is settled when the process is spawned.
* The next thing said to the session starts it again in the new one, which is this app's rule for a
* session with no process everywhere else.
* The next thing said to the session starts it again in the new one.
*/
fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) {
requestFromServer(
@@ -581,15 +663,179 @@ fun uploadAttachment(
write(out)
out.write(tail)
},
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote
// machine before answering.
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote machine
// before answering.
readTimeoutMs = 600000,
) { connection ->
connection.jsonObject().getString("id")
}
}
/** Fetches an image the transcript references (produced or uploaded). */
/**
* One entry of a directory on the machine a setup names.
*
* [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link]
* still says it is one. Neither is worked out here -- the machine answers both.
*/
data class DirEntry(
val name: String,
val kind: String,
val size: Long,
val modified: Long,
val link: Boolean,
) {
val isDirectory: Boolean
get() = kind == "directory"
}
/** A directory's entries, and the path the machine resolved the request to. */
data class Listing(val path: String, val entries: List<DirEntry>)
/**
* What reading a file produced.
*
* Four cases, because they are four different things to draw and none is an error the screen can
* shrug off: content, something that is not text, something too big to have sent, and (as
* [ApiException]) the machine's own refusal. A file with nothing in it is [FileContent.Text] with
* an empty string, which is what it is.
*/
sealed class FileContent {
abstract val path: String
abstract val size: Long
abstract val modified: Long
data class Text(
override val path: String,
override val size: Long,
override val modified: Long,
/** What a write is given back, to prove the file is still the one that was read. */
val sha256: String,
val content: String,
) : FileContent()
data class Binary(
override val path: String,
override val size: Long,
override val modified: Long,
) : FileContent()
data class TooBig(
override val path: String,
override val size: Long,
override val modified: Long,
) : FileContent()
}
/** What a file is after a write, so the editor's precondition is fresh without a second read. */
data class Written(val size: Long, val modified: Long, val sha256: String)
/** Everything in [path] on the machine [setup] names, and what [path] resolved to. */
fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/dir?path=${path.urlEncoded()}",
readTimeoutMs = 30000,
) { connection ->
val body = connection.jsonObject()
Listing(
path = body.getString("path"),
entries =
body.getJSONArray("entries").mapObjects { entry ->
DirEntry(
name = entry.getString("name"),
kind = entry.getString("kind"),
size = entry.optLong("size"),
modified = entry.optLong("modified"),
link = entry.optBoolean("link", false),
)
},
)
}
/** One file's content, or which of the reasons there is none to show. */
fun fetchFile(settings: ServerSettings, setup: String, path: String): FileContent =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}",
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any
// of it moves. Well clear of that rather than just above it.
readTimeoutMs = 60000,
) { connection ->
val body = connection.jsonObject()
val at = body.getString("path")
val size = body.optLong("size")
val modified = body.optLong("modified")
when (val kind = body.getString("kind")) {
"text" ->
FileContent.Text(
at,
size,
modified,
body.getString("sha256"),
body.getString("content"),
)
"binary" -> FileContent.Binary(at, size, modified)
"tooBig" -> FileContent.TooBig(at, size, modified)
// A backend that has learned a fifth answer. Reported rather than guessed at: picking
// the nearest of the four would draw something confident about a state never seen.
else ->
throw ApiException(
"The server described this file as \"$kind\", which this app does not know how to show."
)
}
}
/**
* Replaces a file's contents, but only while it still hashes to [ifSha256].
*
* The refusal is a 409 carrying the server's wording, which is what the conflict dialog shows -- an
* agent editing the same file while somebody reads it is the ordinary case here.
*/
fun writeFile(
settings: ServerSettings,
setup: String,
path: String,
content: String,
ifSha256: String,
): Written =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file",
method = "PUT",
jsonBody =
JSONObject()
.put("path", path)
.put("content", content)
.put("ifSha256", ifSha256)
.toString(),
readTimeoutMs = 60000,
) { connection ->
val body = connection.jsonObject()
Written(body.optLong("size"), body.optLong("modified"), body.getString("sha256"))
}
/** Creates an empty file. Refused, with the machine's own words, if the name is already taken. */
fun createFile(settings: ServerSettings, setup: String, path: String) {
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file",
method = "POST",
jsonBody = JSONObject().put("path", path).toString(),
readTimeoutMs = 30000,
) {}
}
/** Creates a directory, with the same refusal as [createFile]. */
fun createDir(settings: ServerSettings, setup: String, path: String) {
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/dir",
method = "POST",
jsonBody = JSONObject().put("path", path).toString(),
readTimeoutMs = 30000,
) {}
}
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
it.inputStream.readBytes()
@@ -598,10 +844,9 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String):
// One rate-limit window, rendered as a labeled bar on the usage screen.
data class UsageWindow(
/**
* The API's own word for which window this is -- "session" for the five-hour one.
*
* How to find a particular window. The label beside it is written for a person to read, so
* matching on it would select nothing the day its wording changes.
* The API's own word for which window this is -- "session" for the five-hour one. The label
* beside it is written for a person to read, so matching on it would select nothing the day its
* wording changes.
*/
val kind: String,
val label: String,
@@ -620,8 +865,8 @@ data class UsageSnapshot(
* What came back: "ok", "notLoggedIn", "unreachable" or "failed".
*
* Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a
* machine somebody chose not to put an account on -- a fact, not a fault -- while the other two
* are faults worth chasing. Collapsing them made a healthy setup read as broken.
* machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made
* a healthy setup read as broken.
*/
val state: String,
/** Why, for the two states that are faults. Absent otherwise. */
@@ -637,8 +882,8 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
provider = snapshot.getString("provider"),
setup = snapshot.optString("setup"),
setupName = snapshot.optString("setupName"),
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok"
// would draw an empty card as a healthy one.
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would
// draw an empty card as a healthy one.
state = snapshot.optString("state").ifEmpty { "failed" },
detail = snapshot.optString("detail").ifEmpty { null },
windows =
@@ -659,8 +904,7 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
* Answers one question with everything that was chosen.
*
* A list even when one thing was picked, because that is the shape of the answer rather than a
* special case of it. What a provider makes of several answers is its own business and is decided
* on the server; nothing here joins, splits or reformats them for one.
* special case of it. What a provider makes of several answers is decided on the server.
*/
fun answerQuestion(
settings: ServerSettings,
@@ -687,9 +931,8 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
/**
* Ends the process behind a session, leaving the session and its transcript.
*
* Not a delete and not an interrupt: the conversation stays exactly where it is and [startSession]
* picks it back up. The server reports what it could not do -- there was nothing running, or the
* machine would not say whether there was -- rather than answering the same way either way.
* Not a delete and not an interrupt: the conversation stays where it is and [startSession] picks it
* back up. The server reports what it could not do rather than answering the same way either way.
*/
fun stopSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {}
@@ -707,12 +950,10 @@ fun startSession(settings: ServerSettings, sessionId: String) {
* caller confirms first; see ImportScreen.
*
* The work runs on the server, so this returning is not the same as it being done -- what says that
* is each row's own state, through [fetchImportable] and the change stream. That is the point:
* leaving the screen used to cancel the delete it had started.
* is each row's own state. That is the point: leaving the screen used to cancel the delete.
*
* One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per
* row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were
* missed looked exactly like two that had not been picked.
* One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant
* a batch could half-arrive, and the rows that were missed looked exactly like rows not picked.
*/
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
requestFromServer(
@@ -729,8 +970,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<S
* Separate from [spawnSession] because the two are asked different questions. That one means "start
* this and take me to it", so it waits and answers with the session. This is the import list's
* batch: several at once, nobody waiting on any particular one, and the result arrives as a row
* changing rather than as a reply -- which is what lets the screen be left. One request for all of
* them, for the reason [deleteImportable] gives.
* changing -- which is what lets the screen be left.
*/
fun startImport(
settings: ServerSettings,
@@ -760,30 +1000,39 @@ fun startImport(
*
* One request instead of one stream frame per event. The SSE stream is the right shape for live
* events and the wrong one for a backlog: opening an imported session replayed hundreds of frames
* before anything was readable, which looked exactly like the app loading top-down, because it was.
* before anything was readable, which looked exactly like the app loading top-down.
*
* [before] pages backwards for history somebody scrolls to; absent means the newest page.
*/
fun fetchTranscript(
settings: ServerSettings,
sessionId: String,
address: TranscriptAddress,
before: Long? = null,
limit: Int = 80,
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page
// of a delta-heavy conversation is a page of the screen rather than a fraction of one message.
// The scroll-back pager wants this; the anchor restore does not (it counts events to a known
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs.
// See the server's `read_window`.
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
// a delta-heavy conversation is a page of the screen rather than a fraction of one message. The
// scroll-back pager wants this; the anchor restore does not. Ignored by the server for the
// newest window, where the live cursor needs real seqs.
coalesce: Boolean = false,
): List<SeqEvent> {
// Return nothing at or below this seq, stopping the page here instead of at [limit]. The phone
// passes the end of the run it already holds cached, so a page never overlaps that copy -- an
// overlap it cannot store, since a coalesced event cannot be cut inside its own delta run.
after: Long? = null,
): List<Pair<String, SeqEvent>> {
val query = buildString {
append("?limit=").append(limit)
if (before != null) append("&before=").append(before)
if (coalesce) append("&coalesce=true")
if (after != null) append("&after=").append(after)
}
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
return requestFromServer(settings, "/${address.urlPath}/transcript$query") { connection ->
val body = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until body.length()).map { parseSeqEvent(body.getJSONObject(it).toString()) }
// The text as well as the event: the transcript cache stores the one and the fold needs the
// other, and they have to be the same line.
(0 until body.length()).map {
val line = body.getJSONObject(it).toString()
line to parseSeqEvent(line)
}
}
}
@@ -793,7 +1042,7 @@ fun fetchTranscript(
* The name is the backend's own -- it is what the list shows and it exists before any process does
* -- so this settles it rather than asking. Where the thing running the session has a name of its
* own, the backend passes it on, which is what makes a session the same session in Claude Code's
* picker and to any other agent that lists it.
* picker.
*/
fun renameSession(settings: ServerSettings, sessionId: String, title: String) {
requestFromServer(
@@ -824,6 +1073,56 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
*/
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
/**
* What a new session's thinking level is when nothing chose one, or null for the CLI's own.
*
* Held by the server rather than by this phone, because a second device would otherwise spawn
* sessions at a level the first one's owner never picked.
*/
fun fetchDefaultEffort(settings: ServerSettings): String? =
requestFromServer(settings, "/defaults") {
it.jsonObject().optString("effort").ifEmpty { null }
}
/** Sets what new sessions start at. Nothing already running changes. */
fun setDefaultEffort(settings: ServerSettings, level: String?) {
requestFromServer(
settings,
"/defaults",
method = "POST",
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
) {}
}
/**
* How hard the model thinks, as `claude --effort` takes them, cheapest first.
*
* Not offered alongside the model and the permission mode on the session's own bar, because it does
* not behave like them: the CLI has a control request for those two and none for this (checked
* against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops
* the process, which is what the working directory beside it in this dialog does, and why it is
* here rather than on a bar whose other controls take effect mid-turn.
*/
val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max")
/** What the picker shows, and sends as null, for a session that has chosen no level. */
const val DEFAULT_EFFORT = "default"
/**
* Records how hard a session thinks and **stops its process**, since the level is read when the
* process is launched. The next message, or Start, runs one that has it.
*
* [level] is null for the CLI's own default.
*/
fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) {
requestFromServer(
settings,
"/sessions/$sessionId/effort",
method = "POST",
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
) {}
}
/** Switches how much a running session asks before acting, also in place. */
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
requestFromServer(
@@ -835,6 +1134,36 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode:
}
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
/**
* What an auto-resume says when nothing else was typed. Mirrors the server's own default, so a
* cleared field shows the word that would actually be sent instead of going blank.
*/
const val DEFAULT_RESUME_MESSAGE = "continue"
/**
* Turns auto-resume on or off and sets what it would say, in one request because they are one
* decision -- see the server's `/sessions/{id}/auto-resume`.
*/
fun setSessionAutoResume(
settings: ServerSettings,
sessionId: String,
autoResume: Boolean,
message: String?,
) {
requestFromServer(
settings,
"/sessions/$sessionId/auto-resume",
method = "POST",
jsonBody =
JSONObject()
.put("autoResume", autoResume)
// Empty means the server's default rather than a session poked with nothing to
// read, which is the same rule the server applies to the field.
.put("message", message?.trim()?.ifEmpty { null } ?: JSONObject.NULL)
.toString(),
) {}
}
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
requestFromServer(
settings,
@@ -885,10 +1214,9 @@ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Bo
requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {}
}
// Models: what this backend has downloaded, what it is downloading, and
// what HuggingFace offers. Browsing is proxied by the server rather than
// done here, because this app trusts exactly one certificate and has no
// general internet trust to spend on huggingface.co.
// Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers.
// Browsing is proxied by the server rather than done here, because this app trusts exactly one
// certificate and has no general internet trust to spend on huggingface.co.
data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long)
@@ -926,6 +1254,26 @@ private fun parseDownload(o: JSONObject) =
error = if (o.has("error")) o.getString("error") else null,
)
/**
* The models on one machine, which is the list a llama.cpp session there can choose from.
*
* Not [fetchModels], which is what the *backend* has downloaded. A session serves its model from
* the machine it runs on, so for a machine reached over ssh those are two different lists -- and
* offering the backend's would name files that are not there, turning a choice that cannot work
* into a session that fails when it tries to load one.
*/
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
LocalModel(
key = m.getString("key"),
repo = m.getString("repo"),
file = m.getString("file"),
bytes = m.getLong("bytes"),
)
}
}
fun fetchModels(settings: ServerSettings): Models =
requestFromServer(settings, "/models") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText())
@@ -1,7 +1,9 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
@@ -29,16 +31,38 @@ import kotlinx.coroutines.withContext
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them.
*
* Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views
* of the same backend, none of them a step down from another -- and what is left in this `when` is
* only what genuinely is a step down: one session, spawning one, and settings. A session's own
* settings are not among them: they are a dialog over the session, which is where the thing they
* change is.
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
* them a step down from another -- and what is left here is only what genuinely is a step down: one
* session, spawning one, and settings.
*/
private sealed class Screen {
data object Main : Screen()
/**
* 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()
data class Session(val summary: SessionSummary) : Screen()
/**
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* is opened from the session list's card rather than from inside the session it belongs to.
*/
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
/**
* One session, with the file explorer over it when [files] is set.
*
* The explorer is a layer on this screen rather than a screen of its own, so the session under
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
* created on every return, refetching the transcript over the tunnel.
*/
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
data object Spawn : Screen()
@@ -50,7 +74,7 @@ private sealed class Screen {
*
* The notification names an id and nothing else, so opening it means fetching the session first.
* [serial] tells two taps on the same session's notification apart, since they are two requests and
* would otherwise compare equal -- see MainActivity, which counts them.
* would otherwise compare equal.
*/
data class SessionOpenRequest(val sessionId: String, val serial: Int)
@@ -58,8 +82,8 @@ data class SessionOpenRequest(val sessionId: String, val serial: Int)
private data class FailedOpen(val request: SessionOpenRequest, val message: String)
/**
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity),
* re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re-
* reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
*
* [openRequest] is the session a notification tap asked for, likewise from MainActivity.
*
@@ -75,12 +99,12 @@ fun AppRoot(
val context = LocalContext.current
val scope = rememberCoroutineScope()
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
// after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
// Bumped whenever another screen changes something the list shows, so
// returning to it refetches instead of showing a stale list.
// Bumped whenever another screen changes something the list shows, so returning to it
// refetches.
var reloadToken by remember { mutableIntStateOf(0) }
// Cleared by the session screen that attached it, not when a newer request arrives: a share
// must be attached exactly once, and only the screen that did it knows that it has.
@@ -90,13 +114,12 @@ fun AppRoot(
share = shareRequest
// A session already open takes it. Otherwise the list is where the choice is made,
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
if (screen !is Screen.Session) screen = Screen.Main
if (screen !is Screen.Session) screen = Screen.Main()
}
}
// A standing condition rather than a per-request failure, so it is
// stated once here instead of appended to every error that might be
// caused by it. Without this the app is simply unreachable and every
// A standing condition rather than a per-request failure, so it is stated once here instead of
// appended to every error it might cause. Without this the app is simply unreachable and every
// screen blames the server or the tunnel for it.
if (!localNetworkAllowed(context)) {
Text(
@@ -111,14 +134,14 @@ fun AppRoot(
val current = settings
if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR
// path lands in MainActivity and recomposes from the top.
// Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity
// and recomposes from the top.
Box(Modifier.imePadding()) {
SettingsScreen(
existing = null,
onSaved = { saved ->
settings = saved
screen = Screen.Main
screen = Screen.Main()
},
onBack = null,
)
@@ -126,21 +149,20 @@ fun AppRoot(
return
}
// The one way back, whichever screen is showing and whether it was
// reached by the system back gesture or a screen's own Back button.
// Every leaf screen can have changed something the list shows, so it
// always refetches.
// The one way back, whichever screen is showing and whether it was reached by the system back
// gesture or a screen's own Back button. Every leaf screen can have changed something the list
// shows, so it always refetches.
val goToMain = {
reloadToken++
screen = Screen.Main
screen = Screen.Main()
}
if (screen !is Screen.Main) {
BackHandler(onBack = goToMain)
}
// Turning a notification into the screen it points at. The id has to be resolved to a session
// first, because that is what SessionScreen is given -- and unlike a list row, which is a
// snapshot the list already fetched, there is nothing here to seed it from.
// first, because that is what SessionScreen is given -- and unlike a list row, there is nothing
// here to seed it from.
//
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
// an app that opens to the session list with no explanation looks like the tap missed.
@@ -170,10 +192,9 @@ fun AppRoot(
)
}
// Every screen but the session takes the keyboard as bottom padding here. The session
// screen deliberately does not: resizing a whole screen on every frame of the keyboard
// animation is the cost that made it lag, so it moves only its composer and transcript --
// see the layout note in SessionScreen.
// Every screen but the session takes the keyboard as bottom padding here. The session screen
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
// the cost that made it lag, so it moves only its composer and transcript.
when (val here = screen) {
is Screen.Main ->
Box(Modifier.imePadding()) {
@@ -182,6 +203,9 @@ fun AppRoot(
reloadToken = reloadToken,
share = share,
onOpen = { screen = Screen.Session(it) },
onOpenSubagent = { summary, subagent ->
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
},
onSpawn = { screen = Screen.Spawn },
onImported = { imported ->
reloadToken++
@@ -189,30 +213,60 @@ fun AppRoot(
},
onSettings = { screen = Screen.Settings },
)
// Its own back handler is registered after MainScreen's, so it is the one the
// platform asks first while a subagent is open -- the same rule the files
// explorer's handler follows over its session, below.
here.subagent?.let { target ->
BackHandler { screen = here.copy(subagent = null) }
// Its own opaque background: this screen was always the sole content under
// the theme's own Surface before, so it never had to paint one -- stacked over
// the list here, the space between its own cards let the list underneath show
// through without this. The same fix FilesScreen needed over its session.
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
key(target.summary.id, target.subagent.id) {
SessionScreen(
settings = current,
summary = target.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = target.subagent,
)
}
}
}
}
is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this
// one showing other rows. SessionScreen remembers a transcript, an open event stream, a
// draft and a scroll position, and without the key Compose keeps all of it across the
// change and merges two conversations -- which crashes the list on the first duplicate
// row key. Only reachable since a notification can move straight from one session to
// another; every other way here passes through [Screen.Main], which disposes it anyway.
// one showing other rows. SessionScreen remembers a transcript, an open stream, a draft
// and a scroll position, and without the key Compose keeps all of it across the change
// and merges two conversations -- which crashes the list on the first duplicate row
// key. Only reachable since a notification can move straight from one session to
// another.
key(here.summary.id) {
// The gesture goes on a box around the screen rather than inside it, so it is the
// outermost thing in the tree and everything within has already had its chance at
// the drag. See [swipeBack]. No imePadding here, for the reason above.
Box(Modifier.swipeBack(goToMain)) {
// A Box so the explorer can be drawn *over* the session rather than instead of it.
// No imePadding here, for the reason above -- the explorer adds its own.
Box {
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToMain,
onFiles = { screen = here.copy(files = it) },
share = share,
onShareTaken = { share = null },
)
// Its own back handler is registered after this screen's, so it is the one the
// platform asks first, and it steps back inside itself before closing.
here.files?.let { target ->
FilesScreen(
settings = current,
target = target,
onClose = { screen = here.copy(files = null) },
)
}
}
}
is Screen.Spawn ->
Box(Modifier.imePadding().swipeBack(goToMain)) {
Box(Modifier.imePadding()) {
SpawnScreen(
settings = current,
onSpawned = { spawned ->
@@ -223,7 +277,7 @@ fun AppRoot(
)
}
is Screen.Settings ->
Box(Modifier.imePadding().swipeBack(goToMain)) {
Box(Modifier.imePadding()) {
SettingsScreen(
existing = current,
onSaved = { saved ->
@@ -237,8 +291,7 @@ fun AppRoot(
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
// the activity puts around this, and that Box paints in the order it was given. A session
// wanting attention is not a fact about the page somebody happens to be on, so it is not the
// page's job to leave room for it. Tapping one is the same act as tapping a notification, so
// it goes through the same `open`, failure dialog included.
// wanting attention is not a fact about the page somebody happens to be on. Tapping one is the
// same act as tapping a notification, so it goes through the same `open`.
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
}
@@ -41,13 +41,12 @@ data class QuestionAnswer(val questionId: String, val answers: List<String>)
* What the reader has settled on for one question, before any of it is sent.
*
* Held here rather than inferred from the transcript, which is what made picking an option feel
* broken: the mark used to appear only when the answer had crossed the tunnel, been recorded and
* come back as an event, so on a phone the card sat unchanged for most of a second after a tap and
* the natural response was to tap again.
* broken: the mark used to appear only when the answer had crossed the tunnel and come back as an
* event, so the card sat unchanged for most of a second after a tap.
*
* Picked options and typed words are one field each because they are alternatives rather than
* parts: answering in the reader's own words is the case no option covers, so typing puts the picks
* away and picking puts the words away, and there is never a draft that means two things.
* parts: typing puts the picks away and picking puts the words away, so there is never a draft that
* means two things.
*/
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
val settled: Boolean
@@ -65,29 +64,26 @@ data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
/**
* Every question one tool call is waiting on, one at a time.
*
* All of it comes from the question events themselves -- what each option means, what picking it
* would produce, whether several may be picked at once. None of it is read out of the call's own
* All of it comes from the question events themselves. None of it is read out of the call's own
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
* app, where no other provider can reach it and where it drifts the first time the schema moves.
*
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
* three questions with four options and a description each is several screens tall, so the reader
* scrolls past the question they are answering to reach the button that sends it, and never sees
* the whole of any one of them. Paged, each question is a screen and the count says how many are
* left -- which is also what makes "not all of them are answered" something the reader can act on
* rather than something to go hunting for.
* scrolls past the question they are answering to reach the button that sends it. Paged, each
* question is a screen and the count says how many are left.
*
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
* asked them together and is waiting on all of them, and sending each as it was tapped meant the
* reader could not change their mind about the first after reading the third.
* asked them together, and sending each as it was tapped meant the reader could not change their
* mind about the first after reading the third.
*/
@Composable
fun AskUserQuestionBody(
asks: List<TranscriptItem.QuestionCard>,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) {
// Seeded from what was already answered, so a card the reader comes back to shows their
// answers rather than an empty draft over them.
// Seeded from what was already answered, so a card the reader comes back to shows their answers
// rather than an empty draft over them.
var drafts by
remember(asks.map { it.id }) {
mutableStateOf(
@@ -125,7 +121,7 @@ fun AskUserQuestionBody(
modifier = Modifier.weight(1f),
)
// Disabled at the ends rather than absent, so the pair keeps its place and the
// reader can see that there is nothing further that way.
// reader can see there is nothing further that way.
MarkButton("Previous question", { at-- }, enabled = at > 0) {
Chevron(Pointing.Left, colour = LocalContentColor.current)
}
@@ -143,7 +139,7 @@ fun AskUserQuestionBody(
if (outstanding.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
// Greyed until every question has an answer, because the tool is waiting on all of
// them: a submit that sent two of three would leave the third one asked and the card
// them: a submit that sent two of three would leave the third asked and the card
// looking dealt with.
val ready = outstanding.all { drafts[it.id]?.settled == true }
Button(
@@ -155,8 +151,8 @@ fun AskUserQuestionBody(
}
) {
// Back to a button whatever happened. A refusal is reported by the screen
// around this, and the draft is still here to send again -- a spinner
// that never stops would be the only sign of a failure this card cannot
// around this, and the draft is still here to send again -- a spinner that
// never stops would be the only sign of a failure this card cannot
// describe.
sending = false
}
@@ -165,8 +161,8 @@ fun AskUserQuestionBody(
modifier = Modifier.fillMaxWidth(),
) {
if (sending) {
// In the button rather than beside it, so the row does not change height at
// the moment it is pressed.
// In the button rather than beside it, so the row does not change height at the
// moment it is pressed.
CircularProgressIndicator(
Modifier.height(18.dp).width(18.dp),
strokeWidth = 2.dp,
@@ -186,8 +182,7 @@ fun AskUserQuestionBody(
* One question: what is being asked, what can be answered, and what was.
*
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
* when nothing did. A question is the same thing either way, and two renderings of it would be two
* places for an answer to go missing.
* when nothing did. Two renderings of it would be two places for an answer to go missing.
*
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
* sends anything. An answered question ignores both and draws what was answered.
@@ -214,10 +209,10 @@ fun AskedQuestion(
// replacing them with a line repeating it. The options are what the question *was*, and
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
// very little without the three it was chosen over. Marked in the same purple that says
// "picked" while the question is still open, so it is one appearance learned once.
// "picked" while the question is open, so it is one appearance learned once.
val answered = ask.answers.isNotEmpty()
// What is marked: what was answered once there is an answer, and what the finger has
// chosen until then.
// What is marked: what was answered once there is an answer, and what the finger has chosen
// until then.
val marked = if (answered) ask.answers.toSet() else draft.picked
// Null once the question is answered: the options stay and stop being pressable.
val onPick: ((String) -> Unit)? =
@@ -233,9 +228,9 @@ fun AskedQuestion(
}
}
}
// What was answered in the reader's own words, which no option can mark -- see
// [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered
// by picking says it by the mark alone.
// What was answered in the reader's own words, which no option can mark. Only ever the
// answers that match nothing offered, so a question answered by picking says it by the
// mark.
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
if (inWords.isNotEmpty()) {
Text(
@@ -252,10 +247,8 @@ fun AskedQuestion(
}
/**
* [label] added to, or taken out of, what [draft] has picked.
*
* A single-answer question replaces rather than accumulates, and either way picking puts any typed
* words away -- see [Draft].
* [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces
* rather than accumulates, and either way picking puts any typed words away -- see [Draft].
*/
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
when {
@@ -269,8 +262,7 @@ private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
*
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
* should have been, which is the failure a tint step routinely produces on a dark theme. A border
* is one cue and it is unambiguous.
* should have been. A border is one cue and it is unambiguous.
*/
@Composable
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
@@ -324,8 +316,8 @@ private fun Preview(preview: String) {
preview,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines
// of the thing being previewed.
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of
// the thing being previewed.
softWrap = false,
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
)
@@ -336,8 +328,7 @@ private fun Preview(preview: String) {
* The choice the asker always leaves open, and the app has to as well.
*
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
* rather than pick. Leaving it out narrows a question that was never that narrow, and the reader
* cannot tell that it was ever open.
* rather than pick. Leaving it out narrows a question that was never that narrow.
*/
@Composable
private fun OtherAnswer(text: String, onText: (String) -> Unit) {
@@ -358,8 +349,7 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) {
*
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
* with four options showed the first one or two and dropped the rest off the side of the screen.
* That does not read as a bug: it reads as those having been the only choices, which is the worst
* way for a list of choices to be wrong.
* That reads as those having been the only choices.
*/
@Composable
fun AnswerOptions(
@@ -379,8 +369,8 @@ fun AnswerOptions(
OutlinedButton(
onClick = { onPick?.invoke(option.label) },
// Disabled rather than removed, so an answered question still shows what it
// offered. Material dims a disabled button's own border and label, which would
// take the mark with it -- both are stated here instead.
// offered. Material dims a disabled button's own border and label, which would take
// the mark with it -- both are stated here instead.
enabled = onPick != null,
border =
BorderStroke(
@@ -28,8 +28,8 @@ fun attachmentName(ref: String): String = ref.substringAfter('-', ref)
/**
* One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A
* file is not fetched -- there is nothing on this phone to open a trace or a log with -- so the
* name is the whole of it.
* file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all
* of it.
*/
@Composable
fun Attachment(
@@ -50,8 +50,7 @@ fun Attachment(
/**
* A file's name, one line, in the face names are read in. Overlong names lose their middle: a name
* is identified by both ends -- what it is at the front, what kind at the back -- and either
* ellipsis alone takes away one of them.
* is identified by both ends -- what it is at the front, what kind at the back.
*/
@Composable
fun FileName(name: String, modifier: Modifier = Modifier) {
@@ -20,10 +20,8 @@ import kotlin.math.max
* to be either thrown away or rejected -- which is what "sending an image is broken" was.
*
* Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the
* expensive part of this on a phone is the upload, not the decode. What the limit *is* comes from
* the server, per session -- see `DriverKind::max_image_edge` -- because that is where a provider's
* requirements are known, and a phone that carried its own copy of them would be a second place to
* update when one changes.
* expensive part on a phone is the upload, not the decode. What the limit *is* comes from the
* server, per session, because that is where a provider's requirements are known.
*/
suspend fun uploadPickedImage(
context: Context,
@@ -53,18 +51,17 @@ suspend fun uploadPicked(
if (mime != null && mime.startsWith("image/")) {
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
}
// Opened before the request starts, so a provider that refuses says so here and not from
// inside the connection; then streamed, since a trace or a log is bigger than this process
// should hold at once.
// Opened before the request starts, so a provider that refuses says so here and not from inside
// the connection; then streamed, since a trace is bigger than this process should hold at once.
val source = openSource(resolver, uri)
val name = displayName(resolver, uri)
return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out ->
try {
source.use { it.copyTo(out, COPY_BUFFER) }
} catch (e: java.io.IOException) {
// Either side of the copy can fail; the message names the file, which is the
// part the reader can do something about.
throw ApiException("couldn't send $name: ${e.message}", e)
// Either side of the copy can fail; the message names the file, which is the part the
// reader can do something about.
throw ApiException("couldn't send $name: ${e.message}", cause = e)
}
}
}
@@ -76,7 +73,7 @@ private const val COPY_BUFFER = 64 * 1024
*
* A share arrives with whatever access the other app granted, and a provider that refuses says so
* with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both
* are things the reader can act on, so neither is left to end the process.
* are things the reader can act on.
*/
private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream =
try {
@@ -110,9 +107,9 @@ private fun displayName(resolver: ContentResolver, uri: Uri): String {
/**
* The bytes to upload and what they are, scaled down only if they need to be.
*
* An image already inside the limit is uploaded exactly as it came, rather than decoded and
* re-encoded to the same size: a round trip through JPEG loses a little every time, and there is
* nothing to gain from it. This is also the path a provider with no limit always takes.
* An image already inside the limit is uploaded exactly as it came, rather than decoded and re-
* encoded to the same size: a round trip through JPEG loses a little every time. This is also the
* path a provider with no limit always takes.
*/
private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> {
val resolver = context.contentResolver
@@ -128,9 +125,9 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteA
// decision it has no business making.
if (longest <= 0 || longest <= maxEdge) return original to mime
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding
// the full twelve megapixels only to shrink it is how this runs out of memory on the images
// it most needs to handle.
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding the
// full twelve megapixels only to shrink it is how this runs out of memory on the images it most
// needs to handle.
val decode =
BitmapFactory.Options().apply {
inSampleSize = Integer.highestOneBit(max(1, longest / maxEdge))
@@ -141,9 +138,8 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteA
val matrix = Matrix()
if (scale < 1f) matrix.postScale(scale, scale)
// The camera writes which way up the picture is into EXIF rather than rotating the pixels, and
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side, with
// nothing anywhere saying so. Applied to the same matrix as the scale, so it costs no second
// copy of the bitmap.
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side.
// Applied to the same matrix as the scale, so it costs no second copy of the bitmap.
matrix.postRotate(exifRotation(original))
val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
val out = ByteArrayOutputStream()
@@ -166,8 +162,8 @@ private fun exifRotation(bytes: ByteArray): Float =
else -> 0f
}
} catch (_: java.io.IOException) {
// No EXIF, or none this can read. Upright is the assumption every
// image without the tag is displayed under anyway.
// No EXIF, or none this can read. Upright is the assumption every image without the tag is
// displayed under anyway.
0f
}
@@ -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()})"
}
}
@@ -8,18 +8,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
// The composer's row of settings and pickers, and the menus they open. One file because the
// outline and the corner are one appearance: a control shaped like this opens a surface shaped
// like this, and a reader learns the pair once.
// The composer's row of settings and pickers, and the menus they open. One file because the outline
// and the corner are one appearance: a control shaped like this opens a surface shaped like this.
/**
* A bordered pill: a control that can be seen without being pressed.
*
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
* all until they are touched. Three bare words sitting under the message field read as a caption
* about the field rather than as three things to press, and the only way to find out otherwise was
* to press one. The outline says "control" without the weight of a filled button, which is reserved
* here for the two that act on the session (send, and start/stop).
* all until they are touched. Three bare words under the message field read as a caption about the
* field rather than as three things to press. The outline says "control" without the weight of a
* filled button, which is reserved for the two that act on the session.
*/
@Composable
fun BubbleButton(
@@ -32,8 +30,8 @@ fun BubbleButton(
onClick = onClick,
enabled = enabled,
shape = BubbleShape,
// A text button's padding rather than a filled button's 24dp: these sit three across
// under the message field, and the wider padding is what decides whether the row fits.
// A text button's padding rather than a filled button's 24dp: these sit three across under
// the message field, and the wider padding is what decides whether the row fits.
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier,
) {
@@ -48,7 +46,6 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
* The corner on a menu one of these opens.
*
* A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding
* ends that tall would bow its sides. This is the roundest corner that still leaves a straight edge
* beside a one-line option, which is the shortest menu here.
* ends that tall would bow its sides.
*/
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
@@ -26,20 +26,16 @@ import androidx.compose.ui.unit.dp
* of the operation over it.
*
* One composable rather than a pattern each list repeats, because "this row is busy" has to look
* the same in the import list and the session list or the appearance becomes a per-screen dialect
* rather than something the reader learns once.
* the same in the import list and the session list or the appearance becomes a per-screen dialect.
*
* [label] names the operation and `null` means none is running. One parameter rather than a boolean
* beside a string, which can disagree: there is no such thing as busy with nothing happening. It is
* a *word* because a spinner alone cannot say which operation this is deleting and importing are
* different in kind, and losing a session to the wrong one is not recoverable by waiting.
* beside a string, which can disagree. It is a *word* because a spinner alone cannot say which
* operation this is -- deleting and importing are different in kind.
*
* It does **not** make the row inert; the caller disables its own click handling while it passes a
* label. That was the other way round at first an overlay consuming pointer events, so no caller
* had to remember — and it swallowed the drag along with the tap, which meant a list could not be
* scrolled while anything in it was busy. Consuming taps but not drags means re-deciding what a
* gesture is above the components that already decide it; disabling the click is the platform's own
* answer and leaves the scroll where it belongs.
* label. That was the other way round at first -- an overlay consuming pointer events -- and it
* swallowed the drag along with the tap, so a list could not be scrolled while anything in it was
* busy.
*/
@Composable
fun BusyItem(label: String?, content: @Composable () -> Unit) {
@@ -71,14 +67,12 @@ fun BusyItem(label: String?, content: @Composable () -> Unit) {
/**
* How an item looks while it is being acted on: darker, and nearly grey.
*
* Both, rather than either alone. Dimming by itself is what this app already used for a row on its
* way out, and it is the same cue as a disabled control, so a busy row read as one more thing that
* could not be tapped. Draining the colour is what says the row is *suspended* — the status word,
* the accent on a warning and everything else that means something by its colour stop meaning it
* for as long as the operation runs, which is exactly true: none of them is being kept up to date.
* Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a
* busy row read as one more thing that could not be tapped. Draining the colour is what says the
* row is *suspended* -- the status word and everything else that means something by its colour stop
* meaning it for as long as the operation runs, which is exactly true.
*
* Not all the way to grey. A row with no colour left is hard to find again in a list, and the
* reader is watching this one.
* Not all the way to grey: a row with no colour left is hard to find again in a list.
*/
private fun Modifier.busy(busy: Boolean): Modifier =
if (!busy) this
@@ -27,13 +27,10 @@ enum class Pointing {
*
* One composable for all four directions rather than one per axis that differ by which coordinate
* gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one
* direction. The shape is written once in its own coordinates, where x runs across the opening and
* y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto
* the box.
* direction. The shape is written once in its own coordinates, and [Pointing] is only a table of
* how those map onto the box.
*
* It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole
* of what assistive technology has to go on, and it is also the answer to "what was that arrow for"
* six months from now.
* It draws no label of its own, so every caller owes it a `contentDescription`.
*/
@Composable
fun Chevron(
@@ -30,14 +30,12 @@ import org.intellij.markdown.ast.getTextInNode
* sits on, scrolling sideways rather than wrapping.
*
* The renderer's own fence drew the same block in plain text. The scanner that colours a tool
* call's command colours a reply's code the same way, through [highlighted] and one palette, so a
* `kotlin` fence and the Kotlin a tool wrote are the same colours. A fence in a language [scan] has
* no rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and
* plain is what the reader would have seen before.
* call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool
* wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than
* wrongly coloured.
*
* Finding the code is still the library's: which children of the node are the fence markers, the
* language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence]
* hands out the code and the language and leaves the drawing to the block it is given.
* language word and the code between them is its knowledge of the parser.
*/
@Composable
fun CodeFence(
@@ -67,14 +65,12 @@ fun CodeBlock(
/**
* The code inside a fence or indented block, and the highlighter's language for its info word.
*
* Which children of the node are the fence markers, the language word and the code between them is
* the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called:
* that one is a composable, and the whole point of this function is that [warm] can run it on a
* background thread and highlight the same string the drawing will ask for. Two extractions would
* be two keys, and the warmed answer would be silently missed at every fence.
* Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and
* the whole point here is that [warm] can run this on a background thread and highlight the same
* string the drawing will ask for. Two extractions would be two keys, and the warmed answer would
* be silently missed at every fence.
*
* Null for a fence too short to hold anything -- an unterminated one still arriving, which the
* library skips as invalid.
* Null for a fence too short to hold anything -- an unterminated one still arriving.
*/
fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
val word =
@@ -97,7 +93,6 @@ fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
*
* The renderer's own block, less what nothing here needs: the same background, corner, padding and
* sideways scroll, without the shadow, the border and the empty pointer handler it also carried.
* The vertical margin is the renderer's too, kept so a reply's fences sit where they always have.
*/
@Composable
private fun CodeBlockText(
@@ -117,8 +112,7 @@ private fun CodeBlockText(
.semantics { isTraversalGroup = true }
) {
BasicText(
// No language while the block is still being written, which is what draws it plain;
// see [MarkdownRoot]'s `streaming`.
// No language while the block is still being written, which is what draws it plain.
replies.highlighted(code, language.takeUnless { streaming }),
style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
@@ -137,6 +131,23 @@ private fun CodeBlockText(
fun fenceLanguage(name: String?): Language? =
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
/**
* The highlighter's language for a *file*, from its name.
*
* The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people
* write after the backticks. One table rather than two, so a language added for fences is a
* language added for files and neither can be the one somebody forgot.
*
* The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A
* leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name
* with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain.
*/
fun fileLanguage(name: String): Language? {
val dot = name.lastIndexOf('.')
if (dot < 1) return null
return fenceLanguage(name.substring(dot + 1))
}
private val FENCE_LANGUAGES: Map<String, Language> =
mapOf(
"kotlin" to Language.KOTLIN,
@@ -182,13 +193,14 @@ private val FENCE_LANGUAGES: Map<String, Language> =
"toml" to Language.TOML,
"fish" to Language.FISH,
"json" to Language.JSON,
"markdown" to Language.MARKDOWN,
"md" to Language.MARKDOWN,
)
/**
* Every fence in [parse], as the code and language [highlight] will be asked for.
*
* Walks the whole tree rather than the top level: a fence inside a list item or a quote is drawn
* the same way and costs the same to lex.
* Every fence in [parse], as the code and language [highlight] will be asked for. Walks the whole
* tree rather than the top level: a fence inside a list item or a quote is drawn the same way and
* costs the same to lex.
*/
fun fences(parse: State): List<Pair<String, Language?>> {
val success = parse as? State.Success ?: return emptyList()
@@ -25,8 +25,7 @@ import androidx.compose.ui.unit.dp
* These are the two this app understands, and understanding them is what lets it show them: a
* suggestion while one is being typed, a name in the settings screen that sends one, and a bubble
* that stays up while the session is too busy to run it. Anything else beginning with "/" is passed
* through to whatever runs the session, because a dialect's own vocabulary is its own and grows
* without this list -- it just arrives unannounced and unexplained.
* through, because a dialect's own vocabulary grows without this list.
*/
data class SessionCommand(
/** With the slash, as it is typed and as it is sent. */
@@ -90,8 +89,8 @@ fun CommandSuggestions(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
// The command in the colour commands are, so the suggestion and the
// bubble it becomes are visibly the same thing.
// The command in the colour commands are, so the suggestion and the bubble
// it becomes are visibly the same thing.
if (command.argument == null) command.name
else "${command.name} <${command.argument}>",
style = MaterialTheme.typography.titleSmall,
@@ -117,8 +116,7 @@ fun CommandSuggestions(
* anything appearing here.
*
* [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes
* and reads as having been missed.
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes.
*/
@Composable
fun CommandBubble(text: String, waiting: Boolean = false) {
@@ -128,8 +126,8 @@ fun CommandBubble(text: String, waiting: Boolean = false) {
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
) {
Column(Modifier.padding(12.dp)) {
// Stated beside the fill rather than inherited: a semantic colour has to carry
// its own contrast, because the surface under it will not change to rescue it.
// Stated beside the fill rather than inherited: a semantic colour has to carry its
// own contrast, because the surface under it will not change to rescue it.
Text(text, color = MaterialTheme.colorScheme.inverseOnSurface)
if (waiting) {
Spacer(Modifier.height(6.dp))
@@ -7,12 +7,10 @@ import androidx.compose.ui.Modifier
* The mark a compaction leaves in the transcript.
*
* A divider rather than something anybody said: everything above it is out of the session's context
* now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it
* is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which
* a clear also uses, so the two marks cannot drift apart.
* now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider],
* which a clear also uses, so the two marks cannot drift apart.
*
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it,
* which is the same thing the status line says while the compaction runs.
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it.
*/
@Composable
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
@@ -23,9 +21,8 @@ fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifi
* What to say about a compaction: the two sizes, and nothing else.
*
* The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer
* to why the wait was worth it -- and they are all this says, because a divider is read in passing.
* When they were not reported this says only that a compaction happened, rather than filling in a
* plausible number or explaining at length what was missing.
* to why the wait was worth it. When they were not reported this says only that a compaction
* happened, rather than filling in a plausible number.
*/
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
val pre = item.preTokens
@@ -41,8 +38,8 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String {
* A token count as a reader reads one.
*
* Shared with the status row rather than formatted at each: the divider and the row report the same
* quantity about the same moment, and one of them grouping its thousands while the other did not
* read as two different measurements.
* quantity about the same moment, and one grouping its thousands while the other did not read as
* two different measurements.
*/
fun tokens(count: Long): String = "%,d".format(count)
@@ -50,15 +47,12 @@ fun tokens(count: Long): String = "%,d".format(count)
* What the working indicator says while a compaction is running.
*
* Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a
* compaction has begun and then says nothing until it has finished, so any bar, percentage or
* estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been
* going forty seconds is what a reader actually wants -- it is the difference between waiting and
* going to look at why.
* compaction has begun and then says nothing until it has finished, so any bar or estimate here
* would be this screen's guess wearing a measurement's clothes.
*
* [seconds] is null when this device did not see the compaction start, which is what opening a
* session that is already compacting looks like. That case says only "compacting": no number is the
* honest answer, and a number counted from the moment the screen opened would be wrong in the
* direction that matters, since a compaction somebody is asking about is a long one.
* session that is already compacting looks like. That case says only "compacting": a number counted
* from the moment the screen opened would be wrong in the direction that matters.
*/
fun compactingLabel(seconds: Long?): String =
when {
@@ -12,10 +12,9 @@ import java.util.Locale
* The last crash, kept so the debug button can hand it over.
*
* The alternative is asking somebody to reproduce a crash with the phone plugged into a computer
* and `logcat` running, which is the one thing nobody has set up at the moment it happens -- and a
* crash report that arrives a day later, without the stack, is a guess. This costs one file write
* on a process that is already dying, and it turns "it crashes when I open that chat" into the
* frame it crashed in.
* and `logcat` running, which is the one thing nobody has set up at the moment it happens. This
* costs one file write on a process that is already dying, and it turns "it crashes when I open
* that chat" into the frame it crashed in.
*
* Kept until it is read rather than cleared on the next launch: the app restarts before anybody can
* ask about it, so a log that lives for one session is a log that is never read.
@@ -26,8 +25,7 @@ private const val CRASH_FILE = "last-crash.txt"
* How much of a stack is kept.
*
* This is pasted into a conversation, so it has a budget like any other output written for a
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing, so
* what gets cut is the part nobody reads.
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing.
*/
private const val CRASH_LIMIT = 4000
@@ -35,8 +33,7 @@ private const val CRASH_LIMIT = 4000
* Records uncaught exceptions, then lets the platform do what it was going to do.
*
* Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and
* ends the process, and an app that swallows that instead sits there in an unknown state. This only
* adds a witness.
* ends the process, and an app that swallows that instead sits there in an unknown state.
*/
fun installCrashLog(context: Context) {
val app = context.applicationContext
@@ -12,10 +12,9 @@ import java.util.concurrent.atomic.AtomicLong
*
* Here because the emulator cannot answer the question this is for. Its own scroll sits at the same
* frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost
* is under the floor of what it can measure, and a frame number taken in it says nothing about a
* 120Hz phone. Counts do not have that problem: how many times a row was composed, or a reply
* parsed, is the same number on any machine, and it is the number that says whether the work is
* proportional to what is on screen or to everything ever loaded.
* is under the floor of what it can measure. Counts do not have that problem: how many times a row
* was composed, or a reply parsed, is the same number on any machine, and it is the number that
* says whether the work is proportional to what is on screen or to everything ever loaded.
*
* Always on rather than behind a build flag. What is measured is an atomic increment on paths that
* already allocate lists and parse markdown, and a counter that is only compiled into the build
@@ -90,14 +89,12 @@ object DebugStats {
*
* The draw phase is where Compose's measurement lands as well as its recording -- the platform
* calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three
* different things is high. The transcript times its own measure, its own placement and its own
* recording, and this is the subtraction that was otherwise done by hand in a conversation every
* time a report arrived. What is left over is the framework's per-frame bookkeeping after a layout,
* which grows with how many nodes are alive rather than with how many are on screen.
* different things is high. The transcript times its own measure, placement and recording, and this
* is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout,
* which grows with how many nodes are alive rather than how many are on screen.
*
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
* are not themselves per-frame -- a measurement happens on the frames that need one -- so these are
* shares of an average frame, not a claim about any particular one.
* are not themselves per-frame, so these are shares of an average frame.
*/
fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
if (frames == 0 || drawNanos == 0L) return emptyList()
@@ -122,8 +119,7 @@ fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
* frames went, and what the app did to produce them.
*
* Written for somebody to paste into a conversation, so it is plain text with the units on every
* number -- a report whose reader has to ask what the columns mean costs another round trip, and
* the whole point of it is to save one.
* number -- a report whose reader has to ask what the columns mean costs another round trip.
*/
fun debugReport(
device: String,
@@ -131,6 +127,20 @@ fun debugReport(
frames: List<String>,
accounting: List<String>,
crash: String?,
/**
* P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on
* every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section
* heading only appears when there is something to put under it, so an ordinary copy from the
* render-report button reads exactly as it did before this existed.
*/
extra: List<String> = emptyList(),
/**
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
* [extra].
*/
phaseFrames: List<String> = emptyList(),
): String = buildString {
appendLine("ai-app render report")
appendLine(device)
@@ -145,6 +155,11 @@ fun debugReport(
appendLine("transcript:")
transcript.forEach { appendLine(it) }
appendLine()
if (phaseFrames.isNotEmpty()) {
appendLine("per phase:")
phaseFrames.forEach { appendLine(it) }
appendLine()
}
appendLine("frames:")
frames.forEach { appendLine(it) }
appendLine()
@@ -156,6 +171,11 @@ fun debugReport(
appendLine("work since this was last copied:")
val work = DebugStats.lines()
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
if (extra.isNotEmpty()) {
appendLine()
appendLine("bench:")
extra.forEach { appendLine(it) }
}
}
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* A line across the transcript saying what left the session's context.
@@ -21,11 +25,7 @@ import androidx.compose.ui.unit.dp
* reader scrolling back, both mean "the session no longer has what is above this", and which of the
* two it was is said by the words and the colour.
*
* The rules take [color] too, so the whole divider reads as one mark of one kind rather than a
* coloured phrase sitting in an unrelated grey line.
*
* Written once here rather than styled at each of them, so the two cannot drift into looking like
* different kinds of thing.
* The rules take [color] too, so the whole divider reads as one mark of one kind.
*/
@Composable
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
@@ -44,11 +44,47 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
* The mark a clear leaves.
*
* Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
* compaction it summarises nothing and measures nothing, so there is nothing to report but the
* fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is
* why this does not say it.
* compaction it summarises nothing and measures nothing. Everything above stays on screen and stays
* scrollable -- the reader can see that, which is why this does not say it.
*/
@Composable
fun ClearedRow(modifier: Modifier = Modifier) {
TranscriptDivider("Context cleared", clearedColor, modifier)
}
/**
* The mark running out of quota leaves.
*
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
* place: colour by consequence, so "there is nothing left to spend" is learned once.
*
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
* which is not a promise about when the session picks back up. A limit the session was told no
* reset time for says nothing about one -- that state has its own words rather than a plausible
* number.
*/
@Composable
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
}
/**
* What the row says. Split out so the wording is testable without a screen, since the two states it
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
* that reads the same when it goes wrong.
*
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
*/
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
val at = resetsAt?.let {
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone)
.format(Instant.ofEpochSecond(it.toLong()))
} catch (_: Exception) {
null
}
}
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
}
@@ -10,12 +10,11 @@ private const val DRAFTS = "session-drafts"
*
* On this device rather than on the backend, which is where this app otherwise keeps state so that
* every device sees it. A draft is the case that rule is not about: it is the contents of a text
* box on the phone somebody is holding, written on every keystroke, and half a sentence surfacing
* on another device would be a surprise rather than a convenience. What has been *sent* is the
* server's, and that is the part which has to outlive this phone.
* box on the phone somebody is holding, and half a sentence surfacing on another device would be a
* surprise. What has been *sent* is the server's.
*
* Kept per session id, because the thing being typed belongs to the conversation it is aimed at:
* one shared box would hand a message meant for one session to whichever was opened next.
* Kept per session id: one shared box would hand a message meant for one session to whichever was
* opened next.
*/
fun loadDraft(context: Context, sessionId: String): String =
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty()
@@ -23,11 +22,9 @@ fun loadDraft(context: Context, sessionId: String): String =
/**
* Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep.
*
* The path out is emptying the box, which is what sending does -- so a sent message removes its own
* entry and nothing accumulates for a session in ordinary use. A session *deleted* while it held a
* draft does leave its key behind: pruning those means a pass over the live session list, which
* this file would otherwise have no reason to know about, and the residue is a few bytes per
* session ever abandoned mid-sentence. That is a trade rather than an oversight.
* The path out is emptying the box, which is what sending does. A session *deleted* while it held a
* draft does leave its key behind: pruning those means a pass over the live session list, and the
* residue is a few bytes per session ever abandoned mid-sentence.
*/
fun saveDraft(context: Context, sessionId: String, text: String) {
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit {
@@ -5,13 +5,12 @@ package com.example.aiapp
*
* A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two
* halves, because a short span and a long one are read for different things. Under a minute the
* question is "roughly how long", so only the largest unit is shown and a fraction of it carries
* the rest -- `2.5s`, `30ms`. At a minute or more the question is "how long exactly", so every unit
* that has something in it is written out -- `5d 12h 4m`. Units that are empty are left out rather
* than written as zero, since the labels say which is which and `5d 0h 4m` is only longer.
* question is "roughly how long", so only the largest unit is shown and a fraction carries the rest
* -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something
* in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero.
*
* Sub-second precision is dropped past a minute: nothing that takes days is measured in
* milliseconds, and carrying them would make the common case the widest one.
* milliseconds.
*/
fun formatMillis(ms: Long): String {
if (ms < 0) return "-" + formatMillis(-ms)
@@ -12,9 +12,9 @@ private const val RESET_EVENT = "reset"
*
* The connection and its framing belong to [Sse]; what stays here is what this stream's frames
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor. See SessionScreen.
* saw as the new cursor.
*/
class EventStream(settings: ServerSettings, private val sessionId: String) {
class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
private val stream = Sse(settings)
fun close() = stream.close()
@@ -24,15 +24,21 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
*
* [onReset] fires when the server answers that the cursor is too far behind to continue from:
* everything already displayed is stale and the events that follow are a fresh window, so the
* caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It
* arrives before those events, so a caller that clears on it stays in order.
* caller drops what it holds and rebuilds. It arrives before those events, so a caller that
* clears on it stays in order.
*/
fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name, so this is one or
// the other.
fun run(
after: Long,
onOpen: () -> Unit,
onReset: () -> Unit,
// The frame's own text as well as the event parsed from it: the transcript cache stores the
// one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit,
) {
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name.
if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(parseSeqEvent(data))
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
}
}
}
@@ -2,10 +2,9 @@ package com.example.aiapp
import org.json.JSONObject
// The common event model, mirrored from server/src/session/driver.rs --
// the app renders purely from this stream (replayed from the transcript by
// cursor, then live), so there is no separate "load history" shape to keep
// in sync with it.
// The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from
// this stream (replayed from the transcript by cursor, then live), so there is no separate "load
// history" shape to keep in sync with it.
/** One transcript line: the event plus its resume cursor and time. */
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
@@ -13,8 +12,7 @@ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
/**
* One choice offered in answer to a question.
*
* More than a label because the reader is deciding rather than confirming: what an option means,
* and what picking it would produce, are the things that decide it. Both are absent on a
* More than a label because the reader is deciding rather than confirming. Both are absent on a
* permission, whose Allow and Deny mean exactly what they say.
*/
data class QuestionOption(val label: String, val description: String?, val preview: String?)
@@ -30,13 +28,12 @@ sealed class SessionEvent {
*/
val id: String?,
/**
* What was attached to it, by the ref the files route serves: images, and since 2026-09-03
* any file, told apart by [isImageRef].
* What was attached to it, by the ref the files route serves: images, and any file, told
* apart by [isImageRef].
*
* On the message rather than beside it: these arrived as separate image events until
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
* it, and left this app deciding from adjacency alone which message an image went with --
* something the sender knew and could simply have said.
* it, and left this app deciding from adjacency which message an image went with.
*/
val attachments: List<String>,
) : SessionEvent()
@@ -45,11 +42,10 @@ sealed class SessionEvent {
* A message the server has accepted and the session has not read yet.
*
* From the server, not from this app's memory of what it sent. The pending bubble used to be
* screen state, so leaving the session or restarting the app drew nothing waiting while the
* message was still queued -- and nothing waiting is what "there is nothing" looks like.
* screen state, so leaving the session drew nothing waiting while the message was still queued
* -- and nothing waiting is what "there is nothing" looks like.
*
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
* [CommandSent].
* Resolved by the [UserMessage] carrying the same id.
*/
data class MessageQueued(val id: String, val text: String, val attachments: List<String>) :
SessionEvent()
@@ -59,8 +55,7 @@ sealed class SessionEvent {
*
* Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects
* replays both, and without this one it would put back a bubble for a message that is never
* coming -- with nothing left to resolve it, since the [UserMessage] that normally does is
* exactly what was cancelled.
* coming.
*/
data class MessageDropped(val id: String) : SessionEvent()
@@ -108,17 +103,14 @@ sealed class SessionEvent {
*
* The live Claude Code path only learns a turn was somebody else's when the turn ends, so
* the event arrives below everything it caused; this is what puts it back above it. Null
* for a message read out of a session file, which is already in the right place, and for
* one that started no turn. See the server's `Event::PeerMessage`.
* for a message read out of a session file, and for one that started no turn.
*/
val turnStart: Long? = null,
) : SessionEvent()
/**
* A command the session was asked to run on itself and cannot run yet.
*
* Resolved by [CommandSent] with the same id. A command that ran straight away has only that
* one, so nothing here ever draws a bubble that resolves in the same frame.
* A command the session was asked to run on itself and cannot run yet. Resolved by
* [CommandSent] with the same id; a command that ran straight away has only that one.
*/
data class CommandQueued(val id: String, val text: String) : SessionEvent()
@@ -130,29 +122,26 @@ sealed class SessionEvent {
/**
* What the session is set to, as the session itself reports it.
*
* Either field alone: the two are confirmed separately and by different things. Asking for a
* change is not having one, so this -- not the request -- is what the pickers show.
* Either field alone: the two are confirmed separately. Asking for a change is not having one,
* so this -- not the request -- is what the pickers show.
*/
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/**
* What a turn cost, and how much the model was holding when it ended.
*
* [context] is prompt plus both cache figures, measured by the backend from the turn's own
* usage. Carried on the event rather than summed by the reader, because it is not a sum: a
* conversation's context drops at a compaction and a clear, so adding turns up would report a
* figure the session stopped being true of. Null where the dialect did not say, and on entries
* recorded before the backend sent it -- which leaves the context unmeasured rather than
* unchanged.
* [context] is prompt plus both cache figures. Carried on the event rather than summed by the
* reader, because it is not a sum: a conversation's context drops at a compaction and a clear,
* so adding turns up would report a figure the session stopped being true of. Null where the
* dialect did not say, which leaves the context unmeasured rather than unchanged.
*/
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
/**
* A compaction that finished, and how much context it recovered.
*
* The counts are nullable because the server sends them only when it was told them: a
* compaction whose size nobody measured has to be able to say so, since a zero here would read
* as "recovered nothing" and a made-up number would read as a measurement.
* The counts are nullable because the server sends them only when it was told them: a zero here
* would read as "recovered nothing" and a made-up number would read as a measurement.
*/
data class Compacted(
val preTokens: Long?,
@@ -163,27 +152,35 @@ sealed class SessionEvent {
/**
* The conversation was cleared. Everything above this is still here to read and is no longer in
* the session's context.
*
* An object rather than a class because it carries nothing: what it means is entirely its
* the session's context. An object rather than a class because what it means is entirely its
* position in the transcript.
*/
data object Cleared : SessionEvent()
/**
* The session stopped because its account's usage limit was reached.
*
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
* limit reached|1788546972`, which is not readable by the person it is shown to.
*
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
*/
data class LimitReached(val resetsAt: Double?) : SessionEvent()
data class Error(val message: String) : SessionEvent()
/**
* An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new
* event kind degrades to a placeholder row instead of killing the stream.
* An event type this app build doesn't know -- a newer server. Kept rather than thrown so one
* new event kind degrades to a placeholder row instead of killing the stream.
*/
data class Unknown(val type: String) : SessionEvent()
}
/**
* A JSON array of strings under [name], empty when the field is absent.
*
* Absent is the ordinary case -- most messages carry no attachment, and the server omits the field
* rather than sending an empty list -- so this is the shape every caller wants.
* A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since
* the server omits the field rather than sending an empty list.
*/
private fun JSONObject.stringList(name: String): List<String> {
val array = optJSONArray(name) ?: return emptyList()
@@ -212,8 +209,8 @@ fun parseSeqEvent(json: String): SeqEvent {
SessionEvent.ToolStart(
id = body.getString("id"),
tool = body.getString("tool"),
// Kept as raw JSON text: the input shape is the tool's own
// business, and the UI only ever shows it verbatim.
// Kept as raw JSON text: the input shape is the tool's own business, and the UI
// only ever shows it verbatim.
input = body.get("input").toString(),
)
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
@@ -276,42 +273,43 @@ fun parseSeqEvent(json: String): SeqEvent {
trigger = body.optString("trigger").ifEmpty { null },
)
"cleared" -> SessionEvent.Cleared
"limitReached" ->
SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
)
"error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type)
}
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
}
/**
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
*
* One predicate because two readers have to agree on the list: the session screen's working
* indicator, and the fold's decision that the newest reply is finished. Two copies would drift the
* first time the server grows a state, and the drift would be a reply that never splits or one
* split mid-stream.
*/
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
/**
* The context after [event], given what it was before.
*
* The same rule the server folds with, because the screen has to keep up between page loads: the
* summary it opened with is a measurement from before this stream started, and every event that
* moves the figure arrives here.
* summary it opened with is a measurement from before this stream started.
*
* The two that lower it are the point. A clear takes the conversation away and a compaction
* replaces it with a summary, so a figure measured before either stopped being true at that moment
* -- and carrying it forward is how a session that had just been cleared went on reporting the
* context it no longer had.
*
* Null is "we don't know", which is a state each of them can reach: nothing measured yet, a
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
* since.
* Null is "we don't know", which each of them can reach.
*/
/**
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
*
* One predicate because two readers have to agree on the list: the session screen's working
* indicator, and the fold's decision that the newest reply is finished
* ([TranscriptItem.AssistantMsg.settled]). Two copies would drift the first time the server grows a
* state, and the drift would be a reply that never splits or one split mid-stream.
*/
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
fun contextAfter(current: Long?, event: SessionEvent): Long? =
when (event) {
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a
// turn -- which every context figure is -- rather than unknown.
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn
// -- which every context figure is -- rather than unknown.
is SessionEvent.UsageDelta -> event.context ?: current
is SessionEvent.Compacted -> event.postTokens
is SessionEvent.Cleared -> null
@@ -0,0 +1,162 @@
package com.example.aiapp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
/**
* The largest file this app will open in the editor, in bytes.
*
* Measured on the emulator 2026-09-04, in a debug build, on generated Rust:
*
* | file | lines | scan per keystroke | worst frame record | typing |
* |--------|--------|--------------------|--------------------|-------------------|
* | 32 kB | 917 | 10ms | 183ms | sluggish, correct |
* | 128 kB | 3,633 | 40ms | 2,027ms | characters lost |
* | 1 MB | 28,660 | -- | -- | stops responding |
*
* The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file
* costs 40ms a keystroke, which is survivable, while laying the same text out in one
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- what
* EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single
* text field pays that cost. A line-by-line editor is the way past this.
*
* 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays
* the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not.
*/
const val EDIT_LIMIT = 32L * 1024
/**
* The same file, editable, in the same face and colours it was being read in.
*
* `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement
* that colours a field's own text rather than replacing the field with something that only looks
* like one: the transformation returns the text unchanged and the scanner's spans as styles, so
* [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook
* for styles at all.
*
* The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is
* sized against.
*
* The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because
* there are no rows here -- the field is one text object. It lines up for the same reason the
* viewer's does: nothing wraps, so a logical line is a visual line.
*/
@Composable
fun FileEditor(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
language: Language?,
modifier: Modifier = Modifier,
) {
val style = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface)
val scroll = rememberScrollState()
val count = value.text.removeSuffix("\n").count { it == '\n' } + 1
val gutter = gutterWidth(count, style)
val numbers = remember(count) { (1..count).joinToString("\n") }
val transformation =
remember(language) {
VisualTransformation { text ->
TransformedText(highlight(text.text, language), OffsetMapping.Identity)
}
}
Row(verticalAlignment = Alignment.Top, modifier = modifier.fillMaxWidth()) {
Text(
numbers,
style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
softWrap = false,
modifier = Modifier.width(gutter),
)
// The same gap the viewer puts between its numbers and its code, so switching between
// reading and editing does not move the text sideways under the reader.
Spacer(Modifier.width(GUTTER_GAP))
Box(Modifier.horizontalScroll(scroll)) {
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = style,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = transformation,
)
}
}
}
/**
* What to do about a file that changed on the machine while it was open here.
*
* Three ways out rather than one, and each says what it costs, because there is no answer this app
* can pick on somebody's behalf: an agent editing the same file is the ordinary case here, and both
* versions are somebody's work.
*/
@Composable
fun ConflictDialog(
message: String,
busy: Boolean,
onOverwrite: () -> Unit,
onReload: () -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
// The server's own sentence as the title, rather than a heading of this app's above it
// saying the same thing twice: there is one statement of what happened and it comes from
// the side that found out.
title = { Text(message.replaceFirstChar { it.uppercase() }) },
text = {
Text(
"Overwrite keeps what you typed and loses the other change. " +
"Reload keeps the other change and loses what you typed. " +
"Cancel leaves both alone and keeps you here."
)
},
confirmButton = {
TextButton(onClick = onOverwrite, enabled = !busy) {
Text(if (busy) "Saving..." else "Overwrite")
}
},
dismissButton = {
Row {
TextButton(onClick = onReload, enabled = !busy) { Text("Reload") }
TextButton(onClick = onCancel, enabled = !busy) { Text("Cancel") }
}
},
)
}
/** Leaving an editor with edits in it, which is the one way to lose them by accident. */
@Composable
fun UnsavedDialog(onDiscard: () -> Unit, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Leave without saving?") },
text = {
Text(
"The edits you have made here will be lost. They have not been written to the machine."
)
},
confirmButton = { TextButton(onClick = onDiscard) { Text("Discard") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Keep editing") } },
)
}
@@ -0,0 +1,122 @@
package com.example.aiapp
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
/**
* A file split into lines, with the highlighter's colours already worked out for each one.
*
* The pure half of the viewer, so it has a JVM unit test and so [of] can run off the main thread:
* scanning a megabyte is work, and doing it inside a composable would do it on the drawing thread
* and again on every recomposition.
*
* Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text
* layout is linear in the text. That means each row needs *its* colours, and the scanner answers in
* offsets into the whole file -- so the spans are bucketed here, once, in one pass.
*/
class FileLines
private constructor(
/** The text of each line, without its newline. */
val lines: List<String>,
/** Per line, the spans that fall in it, with offsets relative to that line's start. */
private val spans: List<List<Span>>,
/**
* The longest line, in character columns -- what the viewer sizes every row to.
*
* Every row has to be the *same* width or they scroll sideways by different amounts; see
* [FileViewer]. Columns rather than measured pixels because the face is monospace, so one
* number and one character's advance give the width of the widest line without measuring twenty
* thousand strings.
*/
val columns: Int,
) {
val size: Int
get() = lines.size
/**
* One line, coloured. Built when the row is composed rather than up front: a file has far more
* lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the
* lazy list exists to avoid.
*/
fun line(index: Int): AnnotatedString {
val text = lines[index]
val here = spans[index]
if (here.isEmpty()) return AnnotatedString(text)
val palette = catppuccinSyntax()
return buildAnnotatedString {
append(text)
here.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
}
}
companion object {
/**
* [text] scanned as [language] and cut into lines.
*
* Exactly one trailing newline is dropped before splitting, so a file that ends the way
* text files are supposed to end has the number of lines its author would count -- `wc -l`
* agrees. Without that, every well-formed file gained a phantom empty last line. An empty
* file is one empty line numbered 1, which is what it is.
*/
fun of(text: String, language: Language?): FileLines =
// Timed, and always, for the reason everything else here is: the cost of opening a
// large file is the number that decides whether the server's size limit is right, and
// an instrument that is only in the build nobody is running answers nothing.
DebugStats.timed("file scanned and cut into lines") {
val body = text.removeSuffix("\n")
val lines = body.split('\n')
val scanned = if (language == null) emptyList() else spansOf(body, language)
FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf))
}
/**
* How many columns a line occupies.
*
* A tab counts as eight rather than one, and deliberately upwards: this decides how far the
* viewer can scroll, and over-estimating leaves a little empty space past the longest line
* where under-estimating makes the end of that line unreachable.
*/
private fun columnsOf(line: String): Int {
var count = 0
for (character in line) count += if (character == '\t') 8 else 1
return count
}
/**
* The scanner's spans, in file offsets, as spans per line in line offsets.
*
* One walk down both lists, which is what the scanner's guarantee buys: its spans come out
* ordered, non-overlapping and inside the text. A span crossing a line break is cut at each
* break and appears in each line it covers, because a row is drawn on its own and cannot
* inherit a colour from the row above.
*/
private fun bucket(lines: List<String>, spans: List<Span>): List<List<Span>> {
val out = ArrayList<List<Span>>(lines.size)
var lineStart = 0
var next = 0
for (line in lines) {
val lineEnd = lineStart + line.length
var here: ArrayList<Span>? = null
// Spans that ended before this line begins are behind the walk for good.
while (next < spans.size && spans[next].end <= lineStart) next++
var at = next
while (at < spans.size && spans[at].start < lineEnd) {
val span = spans[at]
val start = maxOf(span.start, lineStart) - lineStart
val end = minOf(span.end, lineEnd) - lineStart
if (end > start) {
(here ?: ArrayList<Span>().also { here = it }).add(
Span(start, end, span.kind)
)
}
at++
}
out.add(here ?: emptyList())
// The newline itself, which is in the text and not in any line.
lineStart = lineEnd + 1
}
return out
}
}
}
@@ -0,0 +1,242 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
@Composable
fun codeStyle(): TextStyle =
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
/**
* [content] scanned off the main thread, then drawn.
*
* Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660
* lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first
* written, that is 460ms of frozen screen at the size the server is willing to send -- long enough
* that the accessibility tree cannot be read, which is what "the app has stopped" looks like.
*
* Keyed on the text and the language, so re-reading the same file does not rescan it.
*/
@Composable
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
var lines by remember(content, language) { mutableStateOf<FileLines?>(null) }
LaunchedEffect(content, language) {
lines = withContext(Dispatchers.Default) { FileLines.of(content, language) }
}
when (val ready = lines) {
null -> CircularProgressIndicator(Modifier.padding(8.dp))
else -> FileViewer(ready, modifier)
}
}
/**
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
*
* A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is
* that each row needs its own colours, which is what [FileLines] works out once and off this
* thread.
*
* Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a
* block and a long line does not silently become three -- which would put the gutter's numbers
* against the wrong text.
*
* **Every row is given the same content width**, and that is what makes the shared scroll state
* behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset
* into *its own* range -- `content width - viewport` -- so with rows of their natural widths a
* short line's range is zero and it never moves while a long one beside it does. Each row also
* writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever
* row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on
* 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range
* looks like.
*
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
* around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise,
* so only the line under the finger stretched. Only possible because every row now has the same
* range.
*
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
* numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width
* is measured from the digit count of the line count in the style it is drawn in.
*
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
* copying it gives the code rather than the code with a number in front of every line.
*/
@Composable
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
val style = codeStyle()
val scroll = rememberScrollState()
val overscroll = rememberOverscrollEffect()
val rows = rememberLazyListState()
val gutter = gutterWidth(lines.size, style)
val content = contentWidth(lines.columns, style)
Box(modifier.fillMaxSize()) {
// One container around the whole file rather than one per line, so a selection can run
// across lines -- the same arrangement the transcript uses.
SelectionContainer {
// The stretch is drawn here, once, over everything this box holds; the rows below only
// feed it. `clipToBounds` because a stretch draws outside the box it came from.
Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) {
items(lines.size) { index ->
Row(verticalAlignment = Alignment.Top) {
// Where the numbers go, drawn from outside this box.
Spacer(Modifier.width(gutter + GUTTER_GAP))
Text(
lines.line(index),
style = style,
softWrap = false,
// The scroll outside the width: the scrolling node's viewport is
// what the row has room for, and its content is the whole file's
// widest line. The shared effect is given to every row and rendered
// by none of them -- see the box above.
modifier =
Modifier.horizontalScroll(scroll, overscroll).width(content),
)
}
}
}
}
}
LineGutter(rows, gutter, style)
}
}
/**
* The line numbers, drawn beside the file rather than in it.
*
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they
* have to stay exactly level with the lines they number. Those two pull in opposite directions.
*
* A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come
* from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during
* measurement, so this composes from the answer the list has just produced rather than one it read
* a frame ago. A `Column` translated by the scroll position could not: the translation would be
* current while the set of numbers was a composition behind, so during a fling the numbers would
* slide against their lines.
*
* The list is measured before this is -- they are siblings in a `Box` and it is declared first.
*
* `onSurfaceVariant`, because a number is not part of the file. The background is painted because
* the stretch can carry the text sideways under this column, and a digit with a smear of code
* behind it reads as a rendering fault.
*/
@Composable
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
val surface = rawSurface
SubcomposeLayout(Modifier.fillMaxHeight().width(width).background(surface).clipToBounds()) {
constraints ->
val visible = rows.layoutInfo.visibleItemsInfo
val numbers = visible.map { item ->
subcompose(item.index) {
Text(
(item.index + 1).toString(),
style = style,
color = colour,
textAlign = TextAlign.End,
maxLines = 1,
)
}
.first()
.measure(Constraints.fixedWidth(constraints.maxWidth))
}
layout(constraints.maxWidth, constraints.maxHeight) {
numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) }
}
}
}
/**
* How wide the widest line number is, measured rather than guessed.
*
* `9` repeated, because digits in a monospace face are all one width -- what matters is how many
* there are. Measuring in the style the numbers are drawn in is what makes this survive a font
* size, a density or a display scale nobody here chose.
*/
@Composable
fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val digits = maxOf(1, lineCount.toString().length)
return remember(digits, style, density) {
with(density) {
measurer.measure(AnnotatedString("9".repeat(digits)), style).size.width.toDp()
}
}
}
/**
* How wide to make every row: the widest line in the file, in this style.
*
* One character measured rather than the line itself, because the face is monospace and measuring
* the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already
* has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of
* rounding.
*
* Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary
* one: a minified file is a single line of a hundred thousand characters, and laying that out as
* one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be
* reached, which is the tolerable half of that trade.
*/
@Composable
private fun contentWidth(columns: Int, style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(columns, style, density) {
val advance = measurer.measure(AnnotatedString("0".repeat(64)), style).size.width / 64f
with(density) { (columns * advance).coerceAtMost(MAX_CONTENT_PX).toDp() }
}
}
/**
* The widest a row may be laid out, in pixels. Well under what `Constraints` can carry, and far
* past any line anybody reads.
*/
private const val MAX_CONTENT_PX = 100_000f
/**
* The space between the numbers and the code. A gap, not an alignment: the two are already aligned
* by the row, and this is only so the digits and the first character are not touching.
*/
val GUTTER_GAP = 8.dp
@@ -0,0 +1,688 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Which machine's files to show, and where to start.
*
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the setups tab -- one more
* caller rather than any new code here.
*/
data class FilesTarget(val setup: String, val setupName: String, val start: String)
/** Where the explorer is: in a directory, or in one file. */
private sealed class Spot(val path: String) {
class Dir(path: String) : Spot(path)
class Doc(path: String) : Spot(path)
}
/**
* The files on the machine a session runs on: browse them, read one, change one.
*
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
* where it opened.
*
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* how one gets asked again on purpose, and creating something refetches the directory it was
* created in.
*/
@Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope()
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below,
// because they are what back has to know about -- and back arrives from two places, the arrow
// and the platform's own gesture, which must mean the same thing.
var editing by remember { mutableStateOf(false) }
var dirty by remember { mutableStateOf(false) }
var askUnsaved by remember { mutableStateOf(false) }
val here = stack.last()
fun go(spot: Spot) {
editing = false
dirty = false
stack = stack + spot
}
fun back() {
when {
editing && dirty -> askUnsaved = true
editing -> editing = false
stack.size > 1 -> {
stack = stack.dropLast(1)
editing = false
dirty = false
}
else -> onClose()
}
}
suspend fun load(path: String, again: Boolean) {
if (!again && listings[path] is LoadState.Loaded) return
listings[path] = LoadState.Loading
listings[path] =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.setup, path))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
BackHandler(onBack = ::back)
Box(
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
// The session under this deliberately takes no keyboard inset, so the explorer adds its
// own -- otherwise the editor types under the keyboard.
.imePadding()
) {
Column(Modifier.fillMaxSize()) {
when (val spot = here) {
is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading
// The resolved path once there is one: a directory opened as `~` is called what
// it turned out to be, not what it was asked for.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
FilesHeader(
title = baseName(at),
path = at,
machine = target.setupName,
onBack = ::back,
) {
GlyphButton(
REFRESH_GLYPH,
"Refresh this directory",
{ scope.launch { load(spot.path, again = true) } },
enabled = state !is LoadState.Loading,
)
GlyphButton(
PLUS_GLYPH,
"Create here",
{ creating = true },
enabled = state is LoadState.Loaded,
)
}
LaunchedEffect(spot.path) { load(spot.path, again = false) }
DirectoryBody(state, onOpen = ::go)
}
is Spot.Doc ->
DocPane(
settings = settings,
target = target,
path = spot.path,
name = baseName(spot.path),
editing = editing,
onEditing = { editing = it },
onDirty = { dirty = it },
onBack = ::back,
)
}
}
}
if (askUnsaved) {
UnsavedDialog(
onDiscard = {
askUnsaved = false
editing = false
dirty = false
},
onCancel = { askUnsaved = false },
)
}
val dir = here as? Spot.Dir
val listing = (listings[dir?.path] as? LoadState.Loaded)?.value
if (creating && dir != null && listing != null) {
CreateDialog(
settings = settings,
setup = target.setup,
directory = listing.path,
onDismiss = { creating = false },
onCreated = { path, isDirectory ->
creating = false
scope.launch {
// The directory it was created in is the one thing that changed, so that is
// what gets asked again -- not the whole stack.
load(dir.path, again = true)
// A new file has nothing to look at, so it opens where it can be filled in.
if (!isDirectory) {
go(Spot.Doc(path))
editing = true
}
}
},
)
}
}
/**
* The row every view in here has at the top: back, what this is, and what acts on it.
*
* The path is truncated in the middle when it will not fit, because both ends carry something the
* reader needs -- the machine and the top of the tree at one end, the file at the other -- and it
* is the longest paths, the ones being read most closely, that get cut.
*/
@Composable
private fun FilesHeader(
title: String,
path: String,
machine: String,
onBack: () -> Unit,
actions: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
Text(
"$machine · $path",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
Row { actions() }
}
}
/**
* What is in a directory.
*
* A listing that failed says why, in the machine's own words, where the rows would be -- never an
* empty list, which is what "there is nothing here" looks like and is the one wrong answer that
* looks like a right one.
*/
@Composable
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
when (state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
is LoadState.Error ->
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp),
)
is LoadState.Loaded -> {
val listing = state.value
val sorted = remember(listing) { sortForDisplay(listing.entries) }
LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
parentOf(listing.path)?.let { parent ->
item("..") {
EntryRow(
glyph = FOLDER_GLYPH,
name = "..",
trailing = null,
onClick = { onOpen(Spot.Dir(parent)) },
)
}
}
if (sorted.isEmpty()) {
item("empty") {
Text(
"Nothing here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
uniqueItems(sorted, key = { it.name }) { entry ->
val path = join(listing.path, entry.name)
EntryRow(
glyph = if (entry.isDirectory) FOLDER_GLYPH else FILE_GLYPH,
name = entry.name,
trailing = trailingOf(entry),
onClick = {
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
},
)
}
}
}
}
}
/**
* What a row says after the name, or nothing.
*
* A symlink says so instead of giving a size, because the size a listing reports for one is the
* length of the path it points at -- a number that looks exactly like a file size and is about
* something else. `other` covers a fifo, a device, and a link whose target is gone: the row still
* appears, because a directory that hid what it held would be lying about being empty.
*/
private fun trailingOf(entry: DirEntry): String? =
when {
entry.link -> "link"
entry.isDirectory -> null
entry.kind == "file" -> humanSize(entry.size) ?: "0 B"
else -> "other"
}
@Composable
private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Glyph(glyph, colour = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(12.dp))
Text(
name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.weight(1f),
)
trailing?.let {
Spacer(Modifier.width(8.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* One file: read, and edited behind the pencil.
*
* Its own composable so that everything about one file -- what came back, what has been typed, and
* whether a save is out -- is remembered under that file's path and thrown away when the reader
* moves to another. What is *not* here is edit mode itself: back has to know about it.
*/
@Composable
private fun ColumnScope.DocPane(
settings: ServerSettings,
target: FilesTarget,
path: String,
name: String,
editing: Boolean,
onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var state by remember(path) { mutableStateOf<LoadState<FileContent>>(LoadState.Loading) }
var draft by remember(path) { mutableStateOf(TextFieldValue()) }
var saving by remember(path) { mutableStateOf(false) }
var saveError by remember(path) { mutableStateOf<String?>(null) }
var conflict by remember(path) { mutableStateOf<String?>(null) }
// The editor's own vertical scroll, hoisted so the gutter and the text move together: they are
// two composables in one row, and a scroll inside either would leave the other behind.
val editScroll = rememberScrollState()
val language = remember(name) { fileLanguage(name) }
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
// Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so
// this is decided before anything is typed rather than discovered by a keyboard that stops
// answering.
val editable = loaded != null && loaded.size <= EDIT_LIMIT
suspend fun fetch() {
state = LoadState.Loading
state =
try {
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got)
} catch (e: ApiException) {
LoadState.failed(e)
}
onDirty(false)
}
LaunchedEffect(path) { fetch() }
val changed = loaded != null && draft.text != loaded.content
LaunchedEffect(changed) { onDirty(changed) }
/** Writes the draft back, [against] being the digest it is allowed to replace. */
fun save(against: String) {
if (saving) return
saving = true
saveError = null
scope.launch {
try {
val written =
withContext(Dispatchers.IO) {
writeFile(settings, target.setup, path, draft.text, against)
}
state =
LoadState.Loaded(
FileContent.Text(
path,
written.size,
written.modified,
written.sha256,
draft.text,
)
)
conflict = null
onDirty(false)
onEditing(false)
} catch (e: ApiException) {
// The one refusal that is a question rather than a message: somebody else's edit is
// on the machine, and which of the two survives is not this app's to decide.
if (e.status == 409) conflict = e.message ?: "It changed on the machine."
else saveError = e.message
} finally {
saving = false
}
}
}
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
if (editing) {
if (saving) {
GlyphSpinner("Saving")
} else {
GlyphButton(
SAVE_GLYPH,
"Save",
{ loaded?.let { save(it.sha256) } },
// Disabled rather than hidden while there is nothing to write: a button that
// comes and goes makes its own absence the signal.
enabled = changed,
)
}
} else {
GlyphButton(
REFRESH_GLYPH,
"Read this file again",
{ scope.launch { fetch() } },
enabled = state !is LoadState.Loading,
)
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable)
}
}
saveError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
// Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
// is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
// conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
// never gets.
if (loaded != null && !editable) {
Text(
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
"${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
is LoadState.Error ->
Text(
current.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
)
is LoadState.Loaded ->
when (val file = current.value) {
is FileContent.Text ->
if (editing) {
FileEditor(
draft,
{ draft = it },
language,
Modifier.verticalScroll(editScroll),
)
} else {
ScannedFile(file.content, language)
}
// Said in words, with the measurement that makes it make sense. Neither of
// these is an empty file and neither is an error, so neither may look like one.
is FileContent.Binary ->
Note(
"This is not text (${humanSize(file.size) ?: "0 B"}), so there is nothing to show."
)
is FileContent.TooBig ->
Note(
"This file is ${humanSize(file.size)}, which is more than the server will " +
"send. Nothing was read, so nothing here is a sample of it."
)
}
}
}
conflict?.let { message ->
ConflictDialog(
message = message,
busy = saving,
onOverwrite = {
// Re-read only to learn what it hashes to *now*, which is the digest an overwrite
// has to be allowed against. The content is deliberately thrown away: overwriting
// is the choice to lose it.
scope.launch {
val fresh =
try {
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
} catch (e: ApiException) {
saveError = e.message
conflict = null
return@launch
}
if (fresh is FileContent.Text) save(fresh.sha256)
else {
saveError =
"It is no longer a text file, so this app will not write over it."
conflict = null
}
}
},
onReload = {
conflict = null
scope.launch { fetch() }
},
onCancel = { conflict = null },
)
}
}
/** A sentence where the file's content would be, for the two states that have no content. */
@Composable
private fun Note(text: String) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(8.dp),
)
}
/**
* Naming one thing in the directory that is open.
*
* A name and a switch, not a name and a body: the editor is where content is typed, and a modal
* with a text area in it is a second editor to keep in step with the first. A created file opens
* straight into edit mode, because an empty file is not something to look at.
*/
@Composable
private fun CreateDialog(
settings: ServerSettings,
setup: String,
directory: String,
onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var isDirectory by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
fun create() {
val chosen = name.trim()
if (busy || chosen.isEmpty()) return
busy = true
error = null
val path = join(directory, chosen)
scope.launch {
try {
withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, setup, path)
else createFile(settings, setup, path)
}
onCreated(path, isDirectory)
} catch (e: ApiException) {
// Beside the button that caused it: this dialog is the only thing on screen that
// knows something was being created, and the reason is usually the name itself.
error = e.message
busy = false
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create in ${baseName(directory)}") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Directory", modifier = Modifier.weight(1f))
Switch(
checked = isDirectory,
onCheckedChange = { isDirectory = it },
enabled = !busy,
)
}
Text(
"A name that is already taken is refused rather than replaced.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
TextButton(onClick = { create() }, enabled = !busy && name.isNotBlank()) {
Text(if (busy) "Creating..." else "Create")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } },
)
}
/**
* Directories first, then by name ignoring case, and stably.
*
* Sorted here rather than by the machine: presentation order is a display decision, and `find`
* answers in whatever order the directory happens to be stored in. Dotfiles are not hidden -- in a
* repository they are half of what matters.
*/
internal fun sortForDisplay(entries: List<DirEntry>): List<DirEntry> =
entries.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
/**
* The directory above [path], or null at the root.
*
* A string operation on a path the *machine* resolved, which is what makes it safe: every listing
* answers with its own `pwd -P`, so there is never a `..` or a symlink left in here to reason
* about, and this app never has to resolve one.
*/
internal fun parentOf(path: String): String? {
val trimmed = path.trimEnd('/')
if (trimmed.isEmpty()) return null
val cut = trimmed.lastIndexOf('/')
return when {
cut < 0 -> null
cut == 0 -> "/"
else -> trimmed.substring(0, cut)
}
}
/** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/')
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
}
internal fun join(directory: String, name: String): String =
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
@@ -19,19 +19,15 @@ import androidx.compose.ui.platform.LocalContext
* The point of splitting it up is that "the scroll is laggy" has two completely different causes
* and one appearance. If the layout-and-measure and draw figures are small and the total is large,
* the time is going into rasterising and compositing, and no amount of doing less work per row will
* move it. If they are large, the work per row is the problem and it is ours to fix. Guessing
* between those two is how a day gets spent rewriting the half that was already fast.
* move it. If they are large, the work per row is the problem and it is ours to fix.
*
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
* broken down into the parts the UI thread is responsible for -- handling input, running
* animations, measuring and laying out, recording the draw -- and the parts after it.
* broken into the parts the UI thread is responsible for and the parts after it.
*
* One of these for the app, like [DebugStats], because the two are read as one report and
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
* and the counters were not, so a report copied after visiting two sessions divided every session's
* work by the newest one's frame count -- and printed the result as a per-frame measurement. It
* said 36.8 seconds of placement inside a 13.5 second window, and left "everything else" clamped at
* 0.00ms (0%), which reads as a screen whose whole cost is this app's own code.
* work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window.
*/
object FrameStats {
private val total = ArrayList<Long>()
@@ -46,6 +42,21 @@ object FrameStats {
private val gpu = ArrayList<Long>()
private var since = System.currentTimeMillis()
/**
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
*/
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
private val phaseMarks = ArrayList<PhaseMark>()
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
@Synchronized
fun markPhase(name: String) {
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
}
@Synchronized
fun add(metrics: FrameMetrics) {
// The first frame after a window opens includes inflating it and is nobody's scroll.
@@ -54,7 +65,7 @@ object FrameStats {
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
// How long the frame waited for the UI thread to be free before it could start. Reported
// because the phases otherwise do not add up to the total, and the gap is the interesting
// part: it is the frame being held up by work that is not the frame's.
// part: the frame being held up by work that is not the frame's.
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
@@ -73,6 +84,7 @@ object FrameStats {
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
it.clear()
}
phaseMarks.clear()
since = System.currentTimeMillis()
}
@@ -99,6 +111,38 @@ object FrameStats {
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
}
/**
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
* that finished before a frame was drawn) still get a line rather than being silently dropped
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
*/
@Synchronized
fun phaseLines(refreshHz: Float): List<String> {
if (phaseMarks.isEmpty()) return emptyList()
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
val lines = ArrayList<String>()
phaseMarks.forEachIndexed { i, mark ->
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
val endMs =
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
else System.currentTimeMillis()
val samples = total.subList(mark.startIndex, endIndex)
val seconds = (endMs - mark.startMs) / 1000.0
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
if (samples.isEmpty()) {
lines += " no frames recorded in this phase"
} else {
val late = samples.count { it / 1_000_000.0 > budget }
lines += " late: $late (${percent(late, samples.size)})"
lines += " " + phase("total ", samples)
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
}
}
return lines
}
/** How long the frames recorded here spent in their draw phase, and how many there were. */
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
@@ -123,7 +167,7 @@ private const val CAP = 20_000
* Records into [FrameStats] for as long as this screen is on it.
*
* The listener is what comes and goes; what it writes into does not, so a report covers the same
* stretch of time as the counters beside it. See [FrameStats].
* stretch of time as the counters beside it.
*
* The listener is handed its own thread because the platform calls it for every frame and the
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
@@ -47,12 +47,11 @@ data class SyntaxPalette(
/**
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
*
* Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the same
* code is the same colours wherever it appears.
* Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever
* it appears.
*
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
* off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text
* which needs no colour of its own -- the style the caller draws it with carries that.
* off the drawing thread.
*
* The timing is the number the highlighter is judged by: the library this replaced took **174ms**
* on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted]
@@ -60,7 +59,7 @@ data class SyntaxPalette(
*/
fun highlight(code: String, language: Language?): AnnotatedString {
if (language == null) return AnnotatedString(code)
val spans = DebugStats.timed("code highlighted") { scan(code, rulesOf(language)) }
val spans = DebugStats.timed("code highlighted") { spansOf(code, language) }
val palette = catppuccinSyntax()
return buildAnnotatedString {
append(code)
@@ -82,8 +81,7 @@ fun highlight(code: String, language: Language?): AnnotatedString {
* to the end of the code, which is also what it looks like while a fence is still being written.
*
* In ordinary code the order of recognition is comment, string, attribute, number, word, and
* finally a single punctuation or mark character. Punctuation and marks are coloured only in
* ordinary code, never inside a string or a comment.
* finally a single punctuation or mark character, which are coloured only in ordinary code.
*/
fun scan(code: String, rules: Rules): List<Span> = Scanner(code, rules).run()
@@ -156,8 +154,8 @@ private class Scanner(private val code: String, private val rules: Rules) {
at += comment.open.length
var depth = 1
while (at < code.length && depth > 0) {
// The closer is tried first so that a language whose two delimiters are the same
// string -- CoffeeScript's `###` -- closes rather than nesting forever.
// The closer is tried first so that a language whose two delimiters are the same string
// -- CoffeeScript's `###` -- closes rather than nesting forever.
if (starts(comment.close)) {
depth--
at += comment.close.length
@@ -49,11 +49,9 @@ private const val DELETING = "deleting"
/**
* What the rows further down a batch say while they wait their turn.
*
* Its own word rather than the operation's, because it is its own state and the difference is the
* kind that matters: nothing has been done to this session yet, so a batch stopped here leaves it
* exactly as it was. Marked from the moment the batch is handed over all the same -- a queued row
* that still looked ordinary was still tappable, and tapping it would import it a second time
* behind the batch already coming for it.
* Its own word rather than the operation's, because nothing has been done to this session yet, so a
* batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over
* all the same -- a queued row that still looked ordinary was still tappable.
*/
private const val WAITING = "waiting"
@@ -62,25 +60,22 @@ private const val WAITING = "waiting"
*
* A batch takes rows out of the list as each one lands, so everything below the one that went
* slides up -- and a tap already on its way then arrives at whichever row moved into that place. On
* this screen that means importing a session nobody chose, which is not something a second tap can
* undo.
* this screen that means importing a session nobody chose.
*
* Swallowed silently rather than shown, because anything drawn on every row a batch passes would be
* a flicker running down the list. Half a second: long enough to cover a tap already travelling
* when the row moved, short enough that it is not in the way of a deliberate one.
* a flicker running down the list.
*/
private const val SETTLE_MS = 500L
/**
* Continuing a Claude Code session the machine already has.
*
* The list is the machine's answer, not this app's: it asks a setup what sessions it holds and
* shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this
* screen into a file reader.
* The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so
* an enrolled phone cannot turn this screen into a file reader.
*
* Holding a row selects it and puts the screen in selection mode, where the options that act on a
* selection appear along the bottom. That exists because these arrive in bulk a machine
* accumulates dozens of abandoned sessions and one confirmation dialog per row is the reason
* selection appear along the bottom. That exists because these arrive in bulk -- a machine
* accumulates dozens of abandoned sessions -- and one confirmation dialog per row is the reason
* clearing them out was not worth doing.
*/
@OptIn(ExperimentalFoundationApi::class)
@@ -91,28 +86,25 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
var chosen by remember { mutableStateOf<Setup?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// What is happening to each row right now, as the word the row shows: "importing" or
// "deleting". A map keyed by id rather than a flag per row, because the rows are rebuilt from
// whatever the server last said and this belongs to the request rather than to the session --
// the same arrangement the session list uses for its deletes.
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
// than a flag per row, because the rows are rebuilt from whatever the server last said and this
// belongs to the request rather than to the session.
var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which rows the reader has picked out. Empty means selection mode is off: there is no
// separate flag, because a selection mode with nothing selected is a state with no controls
// in it and no way to leave except Back.
// Which rows the reader has picked out. Empty means selection mode is off: a selection mode
// with nothing selected is a state with no controls in it and no way to leave except Back.
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
// Failures that belong to one row rather than to the screen, shown on that row. A batch is
// exactly where a single banner fails: nine deletes succeeded and one did not, and the
// banner cannot say which.
// exactly where a single banner fails: nine deletes succeeded and one did not, and the banner
// cannot say which.
var rowErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
// themselves, not a flag, so the dialog can say what it is about.
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
// Same default as the spawn screen, and for the same reason: a phone
// is the wrong place to answer "allow Bash?" forty times.
// Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
// times.
var permissionMode by remember { mutableStateOf("auto") }
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from
// it, so a tap reading it needs no recomposition and there is no timer to cancel when a
// second removal lands on top of the first.
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
// so a tap reading it needs no recomposition.
val movedAt = remember { mutableMapOf<String, Long>() }
fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS
@@ -120,8 +112,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Fetches the list and takes the row states from it.
*
* Taken from the answer rather than kept across the load: the server is what knows what is
* running, and this screen may be opening on work another screen -- or another phone --
* started. Anything held locally would be a second version of that, and the stale one.
* running, and this screen may be opening on work another phone started.
*/
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
try {
@@ -167,13 +158,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Hands [targets] to the server in one request, marking every row it covers.
*
* The request only *starts* the work -- the server runs it and says how each row went on the
* change stream, which is what lets this screen be left while a batch is still going. So there
* is nothing here to wait for and nothing to sequence: the rows are marked, the batch goes, and
* everything after that arrives as an event.
* change stream, which is what lets this screen be left while a batch is still going.
*
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it
* is importing" is a guess -- and the row is inert either way, which is the part that matters.
* is importing" is a guess.
*
* The selection is dropped as the work is handed over, not when it finishes: the screen goes
* back to how it started, and what says the work is happening is the rows it is happening to.
@@ -186,17 +175,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
val ids = targets.map { it.id }
scope.launch {
// One request for the whole batch, not one per row. Sent row by row, a handover was
// only as atomic as the network: the fourth of six could fail, or the screen could be
// left with two still unsent, and what came back was some rows running and some
// untouched -- indistinguishable, on the list, from rows nobody had picked. Now
// either the server has the batch or it has none of it, and this is the one place
// that can be true.
// only as atomic as the network, and what came back was some rows running and some
// untouched -- indistinguishable, on the list, from rows nobody had picked.
try {
withContext(Dispatchers.IO) { send(ids) }
} catch (err: Exception) {
// The server never took it, so nothing is running and no event will arrive to say
// so. This is the one failure the screen must report itself -- and it is now the
// whole batch's failure, which is the point: no row was singled out.
// so. This is the one failure the screen must report itself -- and it is the whole
// batch's failure, which is the point: no row was singled out.
running = running - ids.toSet()
rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
return@launch
@@ -205,19 +191,17 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Then ask what actually happened, if anything still looks outstanding.
//
// The change stream is a broadcast with no memory, so an operation that started and
// finished while it was still connecting is one nothing will ever be said about --
// and the row sits marked for ever. That is not hypothetical: with responses held
// back far enough for the stream to open late, one row of a pair of deletes cleared
// and the other stayed on "waiting".
// finished while it was still connecting is one nothing will ever be said about -- and
// the row sits marked for ever. That is not hypothetical: with responses held back far
// enough, one row of a pair of deletes cleared and the other stayed on "waiting".
//
// The listing is the repair, because it carries the same state the events do. Only
// when something still looks outstanding, so the ordinary case -- where the events
// arrived and the rows are already gone -- does not pay for a second listing, which
// is the most expensive call this screen makes.
// The listing is the repair, because it carries the same state the events do. Only when
// something still looks outstanding, so the ordinary case does not pay for a second
// listing, which is the most expensive call this screen makes.
if (setup != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are
// already saying what is happening to them is the flicker this screen avoids
// everywhere else.
// Quietly: no Loading, because blanking the list to report on rows that are already
// saying what is happening to them is the flicker this screen avoids everywhere
// else.
sessions = fetchInto(setup)
}
}
@@ -225,13 +209,6 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
/**
* Imports [targets], and goes to the session it made when [thenOpen].
*
* One function for the tap and for the bar, differing in that one flag: continuing a session
* and then looking at it is what a tap on a row means, and a batch has several results and no
* reason to pick one of them to become the screen.
*/
/** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) {
val setup = chosen ?: return
@@ -283,13 +260,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
}
// Live changes to what the server is doing to these sessions, for as long as this screen is
// up. The listing already carried the same state when the screen opened -- this is what keeps
// it current afterwards, including for work another screen or another phone started.
// Live changes to what the server is doing to these sessions, for as long as this screen is up.
// The listing already carried the same state when the screen opened -- this is what keeps it
// current afterwards, including for work another phone started.
//
// Failures here are deliberately quiet. There is nothing for a reader to do about a dropped
// event stream, and nothing is lost by one: every state it would have carried is in the next
// listing, which is what Refresh and re-entering the tab already fetch.
// event stream, and every state it would have carried is in the next listing.
val liveChanges = remember {
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
}
@@ -307,8 +283,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
running =
running + (change.session to (change.operation ?: WAITING))
// Gone from the machine either way: a delete removed the
// transcript, an import made it a session, and neither is
// something this list still has to offer.
// transcript, an import made it a session.
"finished" -> {
running = running - change.session
forget(change.session)
@@ -323,17 +298,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
// The screen leaving, not a failure -- and swallowing it would leave this
// loop reconnecting to a stream nobody is watching.
// The screen leaving, not a failure -- and swallowing it would leave this loop
// reconnecting to a stream nobody is watching.
throw e
} catch (_: Exception) {
// Retried below; the listing is the truth in the meantime.
//
// Any failure, not only an [ApiException]. A stream is an optimisation over
// the listing here, so nothing it can do is worth taking the app down for --
// and catching only the failure that was expected means an unexpected one
// reaches the top of the app and closes it, from a screen that is merely
// loading a list.
// Retried below; the listing is the truth in the meantime. Any failure, not
// only an [ApiException]: a stream is an optimisation over the listing here,
// and catching only the expected failure means an unexpected one closes the app
// from a screen that is merely loading a list.
} finally {
stream.close()
}
@@ -351,9 +323,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Nested inside MainScreen's own handler, so it wins while there is a selection.
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last
// row can still be scrolled to while it is up, and nothing is nudged by a number that was
// right for one font size.
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
// can still be scrolled to while it is up.
var barHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
@@ -428,8 +399,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
}
// Beside nothing in particular, because a selection is not one row: the options that act
// on it belong to the screen, and the bottom is where a thumb already is.
// Beside nothing in particular, because a selection is not one row: the options that act on
// it belong to the screen, and the bottom is where a thumb already is.
if (selected.isNotEmpty()) {
val picked =
(sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
@@ -486,8 +457,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* What can be done to the rows that are selected.
*
* Delete and Import only, for now: they are the two things this screen has ever done to a session,
* and an option that appears here has to work on every row in a selection rather than on the one
* somebody was thinking of.
* and an option that appears here has to work on every row in a selection.
*/
@Composable
private fun SelectionBar(
@@ -567,26 +537,23 @@ private fun ImportableList(
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.combinedClickable(
// Off while something is happening to this row --
// see [BusyItem], which draws that but deliberately
// leaves the gestures alone so the list still
// scrolls.
// Off while something is happening to this row -- see
// [BusyItem], which draws that but leaves the gestures
// alone so the list still scrolls.
enabled = running[session.id] == null,
onClick = {
if (settling(session.id)) return@combinedClickable
// In selection mode a tap is a selection, so the
// reader is never one mis-tap away from starting
// a CLI they were only picking rows for.
// reader is never one mis-tap away from starting a
// CLI they were only picking rows for.
//
// Outside it, a tap continues the session --
// except on a row that cannot be continued,
// where it selects instead. That row's only
// remaining action is Delete, and a tap that
// did nothing at all would be a worse answer
// than one that offers the thing it can do.
// Two `--resume` processes on one transcript
// each replay the other's writes, which is why
// this must not simply try.
// Outside it, a tap continues the session -- except
// on a row that cannot be continued, where it
// selects instead. That row's only remaining action
// is Delete, and a tap that did nothing at all
// would be a worse answer. Two `--resume` processes
// on one transcript each replay the other's writes,
// which is why this must not simply try.
if (selecting || session.inUse == "yes")
onToggle(session)
else onOpen(session)
@@ -606,8 +573,7 @@ private fun ImportableList(
Spacer(Modifier.width(8.dp))
// Beside the title, because "which one was I just in" is
// the question this list answers and the order already
// reflects it -- the reader should be able to see the
// ordering they are being given rather than infer it.
// reflects it.
Text(
relativeTime(session.modified),
style = MaterialTheme.typography.bodySmall,
@@ -616,12 +582,11 @@ private fun ImportableList(
}
Spacer(Modifier.height(4.dp))
// The path first, and the only thing here that is cut: it is
// one long value with no natural break, where the lines below
// it are short enough to wrap readably. Cut at the head,
// because a path is identified by its tail and these all
// share a long prefix. By the row's real width rather than a
// character count, which was one guess for every font size
// and screen.
// one long value with no natural break. Cut at the head,
// because a path is identified by its tail and these all share
// a long prefix. By the row's real width rather than a
// character count, which was one guess for every font size and
// screen.
session.cwd
.takeIf { it.isNotEmpty() }
?.let { cwd ->
@@ -648,8 +613,7 @@ private fun ImportableList(
color = warningColor,
)
}
// Reported where it happened, in the server's own words, the
// way every other failure in this app is shown.
// Reported where it happened, in the server's own words.
errors[session.id]?.let { message ->
Spacer(Modifier.height(4.dp))
Text(
@@ -667,29 +631,20 @@ private fun ImportableList(
}
}
/** A byte count at the coarsest unit that still says something, so rows stay comparable. */
private fun humanSize(bytes: Long): String? =
when {
bytes <= 0L -> null
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
bytes >= 1_000L -> "${bytes / 1_000L} kB"
else -> "$bytes B"
}
/** What this session is: the measurements, in the order they are worth knowing. */
private fun statsOf(session: Importable): String =
listOfNotNull(
// Said, because a name and a last message are different claims: one describes the
// session, the other is only what happened last in it.
if (session.named) "named" else null,
// What continuing it costs, which is the question this list is really asked. First
// of the measurements for that reason, and absent rather than zero when nothing has
// been measured -- a session with no turns yet has no figure, not a figure of none.
// What continuing it costs, which is the question this list is really asked. Absent
// rather than zero when nothing has been measured -- a session with no turns yet has no
// figure, not a figure of none.
session.contextTokens?.let { "${it / 1000}k context" },
"${session.lines} lines",
// Kept beside the context figure because the two disagree usefully: most of a large
// transcript is history from before a compaction, which the model is no longer
// given, so a big file can be cheap to continue and a small one expensive.
// transcript is history from before a compaction, so a big file can be cheap to
// continue.
humanSize(session.bytes),
)
.joinToString(" · ")
@@ -698,16 +653,14 @@ private fun statsOf(session: Importable): String =
* Why this session might not be safe to take, if it isn't.
*
* Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind,
* and no shade distinguishes them. The colour is what makes it findable; the words are what make it
* actionable.
* and no shade distinguishes them.
*/
private fun warningOf(session: Importable): String? =
when (session.inUse) {
// What was measured is that a live process on that machine holds this session open. Which
// process is not measured, so it isn't claimed: "a terminal close it there first" sent
// people looking for a window that need not exist. It is just as likely another agent, or
// this app on a session it spawned. Naming a place the reader then can't find turns a
// correct refusal into a wrong instruction.
// process is not measured, so it isn't claimed: "a terminal -- close it there first" sent
// people looking for a window that need not exist. Naming a place the reader then can't
// find turns a correct refusal into a wrong instruction.
"yes" -> "something on that machine is running it"
"unknown" -> "can't tell if it's open"
else -> null
@@ -1,11 +1,14 @@
package com.example.aiapp
/**
* A language the highlighter has rules for.
* A language the highlighter can colour.
*
* The names the reader writes after the backticks are aliases onto these; [fenceLanguage] holds
* that table. A word with no entry there is null, and null is drawn plain, because a fence coloured
* by another language's rules looks highlighted and is wrong in a way the reader cannot see.
*
* Nearly all of them are a row of [RULES], read by one shared scanner. [MARKDOWN] is the one that
* is not; see [spansOf].
*/
enum class Language {
C,
@@ -19,6 +22,7 @@ enum class Language {
JAVASCRIPT,
JSON,
KOTLIN,
MARKDOWN,
PERL,
PHP,
PYTHON,
@@ -45,10 +49,9 @@ data class Rules(
/** Tokens that open a comment running to the end of the line. */
val lineComments: List<String> = emptyList(),
/**
* Whether [lineComments] count only at the start of a word.
*
* The shells need it: `$#`, `${#x}` and `a#b` are not comments, and greying the rest of those
* lines is one of the mistakes this scanner exists to stop.
* Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}`
* and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this
* scanner exists to stop.
*/
val lineCommentsAtWordStart: Boolean = false,
val blockComment: BlockComment? = null,
@@ -59,8 +62,8 @@ data class Rules(
val rawStrings: Boolean = false,
/**
* Rust: `'` opens a character literal only when a backslash or one character and a `'` follow.
* Otherwise it is a lifetime or a label and no string starts -- without this, `'a` opens a
* string that runs to the next apostrophe in the block.
* Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the
* next apostrophe in the block.
*/
val lifetimes: Boolean = false,
)
@@ -83,8 +86,22 @@ enum class Attributes {
LINE_BRACKET,
}
/** The rules for [language]. */
fun rulesOf(language: Language): Rules = RULES.getValue(language)
/**
* The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to
* be made of.
*
* Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner.
* Markdown has none of those, and what a character means there depends on where on the line it
* sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a
* row of rules or an entry in [SCANNERS], and no caller learns which one it got.
*/
fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(language)(code)
// Lazy for the same reason [RULES] is, since it reads it.
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
mapOf(Language.MARKDOWN to ::scanMarkdown)
}
private val C_STYLE = BlockComment("/*", "*/", nests = false)
private val NESTING = BlockComment("/*", "*/", nests = true)
@@ -121,8 +138,8 @@ private val RULES: Map<Language, Rules> by lazy {
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
),
// `###` opens and closes a block comment and `#` opens a line one, which is why the
// scanner tries the block opener first.
// `###` opens and closes a block comment and `#` opens a line one, which is why the scanner
// tries the block opener first.
Language.COFFEESCRIPT to
Rules(
keywords = KEYWORDS_COFFEESCRIPT,
@@ -143,8 +160,8 @@ private val RULES: Map<Language, Rules> by lazy {
keywords = KEYWORDS_FISH,
lineComments = listOf("#"),
lineCommentsAtWordStart = true,
// fish's single quotes escape only `\'` and `\\`, which is what "skip the
// character after a backslash" already does.
// fish's single quotes escape only `\'` and `\\`, which is what "skip the character
// after a backslash" already does.
quotes = listOf(DOUBLE, SINGLE),
),
Language.GO to
@@ -269,10 +286,9 @@ private val RULES: Map<Language, Rules> by lazy {
* The keyword sets.
*
* Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0
* (`SyntaxTokens.kt`, Apache-2.0), the library this scanner replaced, so that no fence which is
* coloured today turns plain. Entries that are not plain words were dropped -- Kotlin's `as?`,
* `!in` and `!is`, Swift's `#if` family, Ruby's `defined?`, CoffeeScript's `=` and `->` -- because
* the word scanner cannot reach them and the library only matched them by luck.
* (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns
* plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family,
* Ruby's `defined?` -- because the word scanner cannot reach them.
*/
private fun words(list: String): Set<String> =
list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet()
@@ -8,7 +8,7 @@ package com.example.aiapp
* empty list, which is the one wrong answer that looks like a right one.
*
* [Loading] and [Error] carry no payload, so they are `LoadState<Nothing>` and this is covariant in
* [T]: one `LoadState.Loading` serves every screen rather than each needing its own.
* [T]: one `LoadState.Loading` serves every screen.
*/
sealed class LoadState<out T> {
data object Loading : LoadState<Nothing>()
@@ -28,14 +28,13 @@ import androidx.compose.ui.layout.layout
import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() {
// Bumped whenever enrollment lands via an aiapp:// intent so the
// composition below re-reads the stored settings.
// Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the
// stored settings.
private var settingsVersion by mutableIntStateOf(0)
// The session a notification tap asked for, or null if nothing has. The
// serial is what makes a second tap on the same session's notification a
// second request: without it the two compare equal and the composition
// below has nothing to react to.
// The session a notification tap asked for, or null if nothing has. The serial is what makes a
// second tap on the same session's notification a second request: without it the two compare
// equal and the composition below has nothing to react to.
private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
private var opens = 0
@@ -43,8 +42,8 @@ class MainActivity : ComponentActivity() {
private var shareRequest by mutableStateOf<ShareRequest?>(null)
private var shares = 0
// Registered up front since permission launchers must be registered
// before the activity reaches STARTED.
// Registered up front since permission launchers must be registered before the activity reaches
// STARTED.
private val requestLocalNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
@@ -52,8 +51,8 @@ class MainActivity : ComponentActivity() {
* The service starts either way, and posts nothing if this is refused.
*
* Deliberately not gated on the answer: the permission can be granted later from Android's own
* settings, and a service that only ever started at the moment it was granted would then stay
* down until the app was launched again -- which is the case notifications exist to avoid.
* settings, and a service that only ever started at the moment it was granted would stay down
* until the app was launched again.
*/
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
@@ -64,21 +63,46 @@ class MainActivity : ComponentActivity() {
// Before anything else that could throw, so the first crash of a launch is caught too.
installCrashLog(this)
// Transparent status bar on every version; the Surface below paints
// through underneath it and content insets itself. Same reasoning
// as dev-updater's MainActivity.
// Transparent status bar on every version; the Surface below paints through underneath it
// and content insets itself. Same reasoning as dev-updater's MainActivity.
enableEdgeToEdge()
// Dark status-bar icons only over a light background, decided from the scheme rather
// than fixed. It was hardcoded to `true` -- dark icons -- which was right against the
// default light surface and became unreadable the moment the app wore Catppuccin Mocha.
// Asking the colour means a future palette change cannot reintroduce that: whatever
// `background` becomes, the icons follow it.
// The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session
// screen against BenchFixture's in-process fake backend, with no enrollment, no network
// permission, and no notification prompt -- none of them mean anything with no server and
// no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built
// to talk to a real backend is made to talk to this instead. Still needs the same
// status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the
// platform's own default from Android 15 on this app's targetSdk, with or without the call
// above, so skipping the padding here put the header's own buttons under the status bar --
// there to look at, but not there for `ui-trace`'s tap-by-label to land on.
if (BuildConfig.FIXTURE_MODE) {
installFixtureNetworkOnce()
BenchFixture.ensureLoaded(this)
setContent {
MaterialTheme(colorScheme = AiAppColors) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
SessionScreen(
settings = BenchFixture.settings,
summary = benchSessionSummary(),
onBack = { finish() },
onFiles = {},
)
}
}
}
}
return
}
// Dark status-bar icons only over a light background, decided from the scheme rather than
// fixed. It was hardcoded to `true`, which was right against the default light surface and
// became unreadable the moment the app wore Catppuccin Mocha.
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
AiAppColors.background.luminance() > 0.5f
// Android 17+ silently drops local-network traffic without this;
// requested up front because a denial is invisible at the socket
// layer (it just times out).
// Android 17+ silently drops local-network traffic without this; requested up front because
// a denial is invisible at the socket layer (it just times out).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
@@ -88,16 +112,14 @@ class MainActivity : ComponentActivity() {
}
handleIntent(intent)
// After enrollment, so a first launch that arrives with a token
// starts the service with something to connect to rather than
// stopping it and waiting for the next launch.
// After enrollment, so a first launch that arrives with a token starts the service with
// something to connect to rather than stopping it and waiting for the next launch.
NotificationService.sync(this)
setContent {
// Selection colours with the theme rather than at each place text is drawn: the
// transcript is one selection container, and a selection that ran from a reply into
// the code block under it would otherwise change colour halfway. See
// [AiAppSelectionColors].
// transcript is one selection container, and a selection that ran from a reply into the
// code block under it would otherwise change colour halfway.
MaterialTheme(colorScheme = AiAppColors) {
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
Surface(modifier = Modifier.fillMaxSize()) {
@@ -107,8 +129,7 @@ class MainActivity : ComponentActivity() {
// the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter -- every frame of the IME animation
// relays out and re-records this whole box.
// the case that made it matter.
Modifier.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
@@ -135,19 +156,16 @@ class MainActivity : ComponentActivity() {
}
.fillMaxSize()
.statusBarsPadding()
// The gesture strip at the bottom of most
// phones. Without it the send row sits under
// the swipe area, where a tap is as likely to
// navigate away as to press a button.
// The gesture strip at the bottom of most phones. Without it
// the send row sits under the swipe area, where a tap is as
// likely to navigate away as to press a button.
//
// No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every
// screen's entire tree per frame -- measured above as most of
// the frame budget. Each screen takes the keyboard itself
// (AppRoot wraps the ordinary ones; the session screen moves
// only its composer and transcript), so the per-frame cost is
// scoped to what actually moves.
// screen's entire tree per frame. Each screen takes the
// keyboard itself, so the per-frame cost is scoped to what
// actually moves.
.navigationBarsPadding()
) {
AppRoot(settingsVersion, openRequest, shareRequest)
@@ -158,9 +176,35 @@ class MainActivity : ComponentActivity() {
}
}
// launchMode="singleTop": an enrollment scan, or a notification tapped
// while the app is open, lands here rather than in a second activity
// instance.
/** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */
private fun benchSessionSummary() =
SessionSummary(
id = BenchFixture.SESSION_ID,
setup = "bench",
setupName = "bench",
provider = "bench",
title = "P0 benchmark",
model = null,
keepsOwnTranscript = false,
permissionMode = null,
effort = null,
takesEffort = false,
imported = false,
notify = false,
autoResume = false,
autoResumeMessage = "",
resumeAt = null,
cwd = null,
contextTokens = null,
maxImageEdge = null,
usageProvider = null,
status = "idle",
lastActivity = 0.0,
subagents = 0,
)
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
// lands here rather than in a second activity instance.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
@@ -171,8 +215,7 @@ class MainActivity : ComponentActivity() {
*
* Three things arrive this way -- a share from another app, and an `aiapp://` URI that is
* either an enrollment code or a notification naming a session. The URIs are told apart by host
* rather than by two entry points, so a further kind is a branch here rather than another
* intent to remember to handle.
* rather than by two entry points, so a further kind is a branch here.
*/
private fun handleIntent(intent: Intent?) {
intent ?: return
@@ -195,8 +238,8 @@ class MainActivity : ComponentActivity() {
}
saveServerSettings(this, settings)
settingsVersion++
// Enrolling is the moment there is a backend to watch, and
// re-enrolling elsewhere is the moment the old one stops being it.
// Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the
// moment the old one stops being it.
NotificationService.sync(this)
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
}
@@ -29,12 +29,10 @@ import androidx.lifecycle.repeatOnLifecycle
* The app's root: one title, and four views of the backend behind it.
*
* These were four screens reached by four words in a row under the title, and the row was already
* full -- the comment it replaced recorded that a fifth would have to go somewhere else. Tabs say
* the same thing in less space and say one more thing besides: that these are places to be rather
* than errands to run. Sessions, the machine's importable history, the models on it and the
* machines themselves are all *the same backend*, looked at four ways, and none of them is a step
* down from another. Settings still is a step down, which is why it stays a pushed screen and keeps
* its own Back.
* full. Tabs say the same thing in less space and say one more thing besides: that these are places
* to be rather than errands to run. Sessions, the machine's importable history, the models on it
* and the machines themselves are all *the same backend*, looked at four ways, and none is a step
* down from another. Settings still is, which is why it stays a pushed screen with its own Back.
*/
private enum class MainTab(val label: String) {
Sessions("Sessions"),
@@ -50,6 +48,8 @@ fun MainScreen(
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
share: ShareRequest? = null,
onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit,
@@ -61,17 +61,12 @@ fun MainScreen(
//
// What these four draw is a snapshot of a backend they are not connected to, so it is only as
// fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A
// phone that was away while the tunnel was down, or that fetched before the network came up,
// came back to "Couldn't reach the server" sitting at the top of a list the server would now
// answer for perfectly well, and nothing took it off until somebody pressed Refresh. A stale
// failure is worse than a stale list: it is a claim about right now.
// phone that was away while the tunnel was down came back to "Couldn't reach the server"
// sitting at the top of a list the server would now answer for perfectly well. A stale failure
// is worse than a stale list: it is a claim about right now.
//
// Through the same token the Refresh button uses, so this is one instruction the tabs already
// understand rather than a second path into each of them -- which is also what makes it cover
// all four rather than the one the report came from.
//
// Not on the first entry: the tab composing already asks, and bumping here would make every
// cold start fetch twice.
// understand. Not on the first entry: the tab composing already asks.
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
var opening = true
@@ -81,9 +76,8 @@ fun MainScreen(
}
}
// A tab the app put over the list has to step back to it rather than fall through to the
// system default, which closes the app -- that reads as a crash to somebody who only meant to
// get back to their sessions. Nested inside AppRoot's handler, so it wins while it is enabled.
// A tab the app put over the list has to step back to it rather than fall through to the system
// default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled.
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
Column(Modifier.fillMaxSize()) {
@@ -98,20 +92,18 @@ fun MainScreen(
)
// Glyphs rather than the words they replaced: neither ever changes, both are read
// faster than they are spelled, and together they take the width that let the title
// keep its own line. They sit on the title's row because they act on the whole
// screen -- everything below this row is one tab's business, and a control belongs
// with the thing it acts on.
// Flush against each other: a glyph button carries its own padding, so two of them
// side by side already have two rings between their marks and one ring plus this
// row's padding to the screen edge.
// keep its own line. They sit on the title's row because they act on the whole screen.
//
// Flush against each other: a glyph button carries its own padding, so two side by side
// already have two rings between their marks.
Row {
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
}
}
// What is waiting to be attached, and what to do about it. Said here because the list
// below is where the choice is made, and a share that arrived with nothing on screen
// saying so would read as a tap that did nothing.
// What is waiting to be attached, and what to do about it. Said here because the list below
// is where the choice is made, and a share that arrived with nothing on screen saying so
// would read as a tap that did nothing.
share?.let {
Text(
it.summary() + " -- open the session it belongs in.",
@@ -127,8 +119,8 @@ fun MainScreen(
.padding(12.dp),
)
}
// Primary rather than the plain TabRow, which is deprecated in favour of the two that
// say where they sit: these are the app's top-level destinations.
// Primary rather than the plain TabRow, which is deprecated in favour of the two that say
// where they sit: these are the app's top-level destinations.
PrimaryTabRow(selectedTabIndex = tab.ordinal) {
MainTab.entries.forEach { entry ->
Tab(
@@ -139,10 +131,9 @@ fun MainScreen(
}
}
// Refreshing means "ask again about what I am looking at", so the button feeds the tab
// that is showing. The token from above means something else already changed what these
// show; the two are the same instruction to the tab below, so they are summed rather than
// tracked apart -- either one moving moves the sum, which is all a tab watches.
// Refreshing means "ask again about what I am looking at", so the button feeds the tab that
// is showing. The token from above means something else already changed what these show;
// the two are the same instruction, so they are summed rather than tracked apart.
val token = reloadToken + refreshToken
when (tab) {
MainTab.Sessions ->
@@ -150,6 +141,7 @@ fun MainScreen(
settings = settings,
reloadToken = token,
onOpen = onOpen,
onOpenSubagent = onOpenSubagent,
onSpawn = onSpawn,
)
MainTab.Import ->
@@ -69,13 +69,10 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
*
* [live] is the reply still arriving, and two things are different for it. Its parse is incremental
* -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole
* message. And its pieces get a layer each: when drawing is invalidated, only the piece that
* changed is re-recorded instead of the whole reply, which is worth a great deal while every delta
* invalidates the message and a finished one can be twenty-five screens tall. It is worth nothing
* once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65
* times in fifty seconds of reading -- and it is not free: each layer is a layout node and a
* display list held for the life of the row, and live node count is what the per-frame cost of the
* transcript scales with.
* message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is
* worth a great deal while every delta invalidates the message and worth nothing once it stops
* changing -- and it is not free: each layer is a layout node and a display list held for the life
* of the row, and live node count is what the transcript's per-frame cost scales with.
*/
@Composable
fun MarkdownText(
@@ -92,8 +89,8 @@ fun MarkdownText(
var previousSegment: Segment? = null
segments.forEachIndexed { at, segment ->
val nextContinues = segments.getOrNull(at + 1)?.continues == true
// Only the tail is still being written; a frozen segment is finished text that
// happens to sit in a live reply, and it takes its colours now. See [MarkdownRoot].
// Only the tail is still being written; a frozen segment is finished text that happens
// to sit in a live reply, and it takes its colours now.
MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) {
segment.pieces.forEachIndexed { index, piece ->
val gap =
@@ -103,9 +100,9 @@ fun MarkdownText(
if (segment.continues) 0.dp else BLOCK_SPACING
else -> gapBefore(previous, piece)
}
// Keyed by where the piece starts in the message rather than by its position
// in this column, so a delta landing in the last block leaves every other
// piece's composition alone -- and a block keeps its key when it freezes.
// Keyed by where the piece starts in the message rather than by its position in
// this column, so a delta landing in the last block leaves every other piece's
// composition alone -- and a block keeps its key when it freezes.
key(segment.start, piece) {
MarkdownPiece(
segment.parse,
@@ -137,8 +134,7 @@ fun MarkdownText(
* A stretch of a message with a parse of its own: the whole of a settled message, or one block, the
* finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins
* in the message. [continues] says the first piece is an item of the list the segment before it
* ended with, so the two draw as one list: no block gap between them, and neither the item above
* the seam nor the one below it takes the padding of a list's edge.
* ended with, so the two draw as one list.
*/
private class Segment(
val text: String,
@@ -154,14 +150,11 @@ private class Segment(
*
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
* height, and the transcript above it collapses and springs back. Seen with five replies on screen
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
* later it was all there again. That is the "skipping up and down" this list must never do.
* height, and the transcript above it collapses and springs back -- seen with five replies on
* screen at once, the whole conversation shrunk to fit a single screen.
*
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
* already has until the new one lands, so there is never a frame without a height. What is on
* screen is always a real prefix of the reply rather than a guess at it; it is simply one parse
* behind.
* already has until the new one lands, so there is never a frame without a height.
*/
@Composable
private fun liveSegments(text: String): List<Segment> {
@@ -187,24 +180,18 @@ private fun liveSegments(text: String): List<Segment> {
* Reparsing the whole message per delta was fine for a short reply and not for a long one: a
* twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran
* off the composing thread it was every core busy while the frame's own thread waited for one.
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it, since a paragraph ends at the blank
* line or the block that interrupts it, a fence at its closing fence, a list at the first line that
* is neither an item nor indented under one. So every block but the last is [frozen] with the parse
* that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed
* again.
*
* A list is cut once more, at its last item, by the same reasoning one level down: an item is
* finished once the next item has begun, since a line can only continue the item it is indented
* under or start a new one. Without this a reply that is one long list -- forty sources -- parsed
* the whole list per delta, and a list streams as forty paragraphs would. The item the cut lands on
* has to have begun in earnest: a bare `-` is an empty item now and the first character of a
* paragraph line once `-x` arrives, and cutting on it would draw that line as a new item.
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it. So every block but the last is
* [frozen] with the parse that finished it, and only the tail is parsed again.
*
* A list is cut once more, at its last item, by the same reasoning one level down. Without this a
* reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut
* lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of
* a paragraph line once `-x` arrives.
*
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
* it, since the frozen block's parse never sees it. The link draws as its brackets until the reply
* settles and is parsed whole by [warm], which is the same moment every other transient of
* streaming is put right.
* it. The link draws as its brackets until the reply settles and is parsed whole by [warm].
*/
private class LiveParse(
val text: String,
@@ -217,8 +204,8 @@ private class LiveParse(
get() = frozen + tail
fun advanceTo(next: String): LiveParse {
// Anything but an append to what was frozen -- a message replaced, a stream reset --
// starts over.
// Anything but an append to what was frozen -- a message replaced, a stream reset -- starts
// over.
if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
val tailText = next.substring(consumed)
val parse = parseMarkdown(tailText)
@@ -264,8 +251,7 @@ private class LiveParse(
/**
* The piece of the tail still being written: the last item of a list of several, or the first
* piece of the last block when there is more than one block. Null when nothing before it is
* finished, so the tail stays whole.
* piece of the last block when there is more than one. Null when nothing before it is finished.
*/
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
val last = all.lastOrNull() ?: return null
@@ -315,28 +301,24 @@ fun MarkdownPiece(
* The renderer's own environment -- its colours, type scale, dimensions, component table and
* reference links -- around whatever draws pieces of [parse].
*
* The parsing is the library's. Markdown is somebody else's specification, and a hand-written
* The parsing is the library's: markdown is somebody else's specification, and a hand-written
* parser would get the edge cases wrong one case at a time. So is the environment: the element
* composables its dispatch reaches read these locals, and providing them once here is what lets a
* piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
* Everything below this is the mapping onto the app's palette and type scale.
*
* The locals are provided directly rather than through the renderer's `Markdown()` composable,
* which was the last of its composables on the hot path and was here only to provide them. What
* that buys is that nothing between a piece and the screen is the library's but the leaf
* composables named in the component table, so a different parser could stand behind [State]
* without the renderer's entry point being involved.
* which was the last of its composables on the hot path and was here only to provide them. So
* nothing between a piece and the screen is the library's but the leaf composables named in the
* component table.
*
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
* Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its
* own.
*
* [streaming] says this parse is the part of a reply still being written, which only the fences
* care about: lexing is proportional to how much code there is, and a fence still arriving is
* re-lexed at every delta on the composing thread. Measured streaming a two-hundred-line Kotlin
* fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- for colours on
* text that was being replaced as fast as they were computed. So a fence still being written is
* drawn plain and takes its colours when the block freezes, which is the same bargain [LiveParse]
* already makes for a reference link defined at the foot of a message.
* care about: lexing is proportional to how much code there is. Measured streaming a two-hundred-
* line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms --
* for colours on text being replaced as fast as they were computed. So a fence still being written
* is drawn plain and takes its colours when the block freezes.
*/
@Composable
private fun MarkdownRoot(
@@ -354,42 +336,40 @@ private fun MarkdownRoot(
CompositionLocalProvider(
LocalReferenceLinkHandler provides parse.referenceLinkHandler,
LocalMarkdownPadding provides markdownPadding(),
// Read by the renderer's own text composable, which no paragraph reaches any more, and
// by its checkbox. Provided so a path that does reach them draws no image rather than
// failing to compose.
// Read by the renderer's own text composable, which no paragraph reaches any more, and by
// its checkbox. Provided so a path that does reach them draws no image rather than failing
// to compose.
LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
LocalMarkdownAnimations provides markdownAnimations(),
LocalMarkdownColors provides
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
// The dark surface every verbatim thing in this app sits on -- see [rawSurface],
// and the tool call above this reply, which now matches. `surfaceVariant` was
// exactly a card's own fill, so a fenced block inside a tool call had no
// background at all and one in a reply read as a step *up* out of the page.
// The dark surface every verbatim thing in this app sits on -- and the tool call
// above this reply, which now matches. `surfaceVariant` was exactly a card's own
// fill, so a fenced block inside a tool call had no background at all.
codeBackground = rawSurface,
// The same colour. Not drawn by the renderer as a span background but by
// [LinkedText] behind the text, so a selection lands on top of it -- see
// `appendCodeChip`.
inlineCodeBackground = rawSurface,
// The same tint a code block gets, rather than the renderer's 2%-alpha default:
// two adjacent tints that differ by a fiftieth read as one flat block on a phone,
// so the table would have had a border-less grid and nothing saying where it began.
// The same tint a code block gets, rather than the renderer's 2%-alpha default: two
// adjacent tints that differ by a fiftieth read as one flat block on a phone.
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
),
LocalMarkdownTypography provides
markdownTypography(
// A ladder that starts near the body text and descends, because these are headings
// inside a chat message rather than the top of a document. The renderer's defaults
// are the Material *display* styles -- `#` came out at 57sp and `##` at 45sp, which
// is bigger than this app's own screen titles and reads as the reply shouting.
//
// Every step is a different size, so two levels of nesting never draw the same:
// one clear step per level is the whole job of a heading.
// are the Material *display* styles -- `#` came out at 57sp, bigger than this app's
// own screen titles. Every step is a different size, so two levels of nesting never
// draw the same.
h1 = MaterialTheme.typography.headlineSmall,
h2 = MaterialTheme.typography.titleLarge,
h3 = MaterialTheme.typography.titleMedium,
h4 = MaterialTheme.typography.titleSmall,
h5 = MaterialTheme.typography.labelMedium,
h6 = MaterialTheme.typography.labelSmall,
// Body text at the size everything else in the transcript uses.
text = body,
paragraph = body,
ordered = body,
@@ -397,16 +377,10 @@ private fun MarkdownRoot(
list = body,
table = body,
// Code in a monospace face, in the ordinary text colour. The face and the tinted
// background are what say "this is code"; colour is not, and it used to be green
// -- the palette's colour for a *literal*. A block of code is not a literal, it
// is text that happens to be code, and painting all of it green said the whole
// block was one. Where a literal really does appear inside code, the thing that
// should colour it is a syntax highlighter looking at the code, which is exactly
// what a tool call's input already gets from `catppuccinSyntax`.
//
// The colour rides on the style here rather than in `markdownColor`, which
// stopped carrying `codeText`/`inlineCodeText`/`linkText` when the renderer moved
// them onto the typography.
// background are what say "this is code"; colour is not, and it used to be green --
// the palette's colour for a *literal*. A block of code is not a literal, and
// painting all of it green said the whole block was one. Where a literal really
// does appear inside code, what should colour it is a syntax highlighter.
code =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
@@ -433,31 +407,26 @@ private fun MarkdownRoot(
LocalMarkdownDimens provides
markdownDimens(
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
// the default a fifth of the narrowest column went on space rather than on words
// -- and the narrowest column is where the wrapping below has the least room.
// the default a fifth of the narrowest column went on space rather than on words.
tableCellPadding = 8.dp,
// What a column narrows to before the table starts scrolling sideways instead. It
// is the floor, not the width: a table with room to spare spreads across it.
//
// Down from the renderer's 160dp, and the number is a measurement rather than a
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
// makes even a three-column table -- the commonest shape there is -- scroll, while
// 136dp fits three across the phone this app is read on. Four and up still scroll,
// which is the right answer for genuinely too many columns: squeezing six columns
// into a phone would give every cell one word per line.
//
// Narrower would fit more, and stop being readable. This is the widest minimum
// that keeps three columns on screen, which is the trade the number is making.
// makes even a three-column table scroll, while 136dp fits three across the phone
// this app is read on. Four and up still scroll, which is the right answer for
// genuinely too many columns. This is the widest minimum that keeps three on
// screen.
tableCellWidth = 136.dp,
),
LocalMarkdownComponents provides
markdownComponents(
// The m3 renderer's own default, restored: supplying `components` at all replaces
// the whole set, and this is the only member of it the Material layer overrides.
// the whole set, and this is the only member the Material layer overrides.
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
// Everything that draws a run of text, so a link is a span rather than a node --
// see [LinkedText]. Setext headings take the same styles as `#` and `##`, which
// is the renderer's own pairing.
// see [LinkedText]. Setext headings take the same styles as `#` and `##`.
text = { LinkedText(it, it.typography.text) },
paragraph = { LinkedText(it, it.typography.paragraph) },
heading1 = { LinkedHeading(it, it.typography.h1) },
@@ -468,8 +437,8 @@ private fun MarkdownRoot(
heading6 = { LinkedHeading(it, it.typography.h6) },
setextHeading1 = { LinkedHeading(it, it.typography.h1) },
setextHeading2 = { LinkedHeading(it, it.typography.h2) },
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote --
// so they draw like the top-level ones the transcript cuts into items.
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so
// they draw like the top-level ones the transcript cuts into items.
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = { LinkedTable(it.content, it.node, it.typography.table) },
@@ -488,14 +457,12 @@ private fun MarkdownRoot(
/**
* A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
*
* Each column has a floor ([markdownDimens]'s `tableCellWidth`), so the table is at least
* columns-times-floor wide; narrower than the room it has, it spreads to fill it, and wider, it
* scrolls sideways rather than squeezing. The renderer decided that with a `BoxWithConstraints`,
* which is a subcomposition; here it is one layout modifier, and the trick is where it sits.
* `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that
* minimum through to its content while lifting the maximum to unbounded, and the modifier after it
* reads the minimum back as the room and sizes the rows to the larger of that and the floor. The
* scroll then has exactly the overflow to scroll, which is none when the table fits.
* Each column has a floor, so the table is at least columns-times-floor wide; narrower than the
* room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The
* renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one
* layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal
* scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after
* it reads the minimum back and sizes the rows to the larger of that and the floor.
*/
@Composable
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
@@ -536,19 +503,15 @@ private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
* One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText].
*
* The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
* most of a table is simply not readable: anything past about twenty characters ends in "..." with
* no way to see the rest, and an elided cell looks like a short one, so a table of measurements
* reads as a table of plausible shorter measurements. And they draw a link in a cell as its own
* layout node, the cost [LinkedText] exists to avoid.
* most of a table is simply not readable: an elided cell looks like a short one, so a table of
* measurements reads as a table of plausible shorter measurements. And they draw a link in a cell
* as its own layout node, the cost [LinkedText] exists to avoid.
*
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
* beside a one-line one centred the short one against the middle of the tall one and lost the line
* the reader was reading across. What the wrapping does *not* do is make a wide table fit;
* [LinkedTable] scrolls it instead, which is the right answer for too many columns -- wrapping a
* six-column table into the width of a phone would give every cell one word per line.
* beside a one-line one centred the short one against the middle of the tall one. What the wrapping
* does *not* do is make a wide table fit; [LinkedTable] scrolls it instead.
*
* The semantics are the renderer's: each cell is an item of the table's collection, and a header
* cell is a heading.
* The semantics are the renderer's: each cell is an item of the table's collection.
*/
@Composable
private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
@@ -584,19 +547,14 @@ private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowI
* Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much
* was written. Measured against a real Claude Code transcript on the emulator, one message took
* **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first
* tuned on -- so a page of history landing composed several rows that each stalled the frame they
* appeared in. That is the lag when a block loads.
* tuned on -- so a page of history landing composed several rows that each stalled the frame.
*
* Nothing here changes what a row does when it has no answer waiting: it parses inline, on the
* composing thread, because a row measured at nothing before it is measured at its real height
* collapses the transcript above it. The point is only that by the time the reader scrolls to a
* row, the answer is usually already made -- [warm] runs on a background thread as each page of
* history arrives, which is seconds before anybody reaches the rows it brought.
* Nothing here changes what a row does when it has no answer waiting: it parses inline, because a
* row measured at nothing before its real height collapses the transcript above it. The point is
* only that by the time the reader scrolls to a row, the answer is usually already made.
*
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page
* warmed and nothing else, so a reply still streaming cannot fill it with hundreds of copies of
* itself on the way to being finished. It is dropped with the screen, and emptied by the stream
* reset that drops the rows it describes.
* warmed, so a reply still streaming cannot fill it with hundreds of copies of itself.
*/
@Stable
class ParsedReplies {
@@ -604,14 +562,13 @@ class ParsedReplies {
/**
* How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per
* fold, and walking the tree again each time is proportional to the message where a lookup is
* proportional to nothing.
* fold, and walking the tree again each time is proportional to the message.
*/
private val pieces = ConcurrentHashMap<String, List<Piece>>()
/**
* How each message divides into prose and memory notes, cached for the same reason as
* [piecesOf]: the regex scan behind [messageParts] is proportional to the message.
* How each message divides into prose and memory notes, cached for the same reason: the regex
* scan behind [messageParts] is proportional to the message.
*/
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
@@ -624,7 +581,7 @@ class ParsedReplies {
* much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator --
* and a lazy list drops the composition of a block that scrolls away, so a `remember` inside
* the fence paid that again every time the reader came back to it. Six times in one scroll,
* measured. [warm] fills this off the drawing thread before the row is reached.
* measured.
*/
private val highlights = ConcurrentHashMap<String, AnnotatedString>()
@@ -646,11 +603,10 @@ class ParsedReplies {
* Whether [warm] has made everything drawing [text] as pieces will look up.
*
* What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole
* message and the flatten runs on the composing thread -- so a reply not marked yet stays
* whole, drawing the parse it already has, until the screen has warmed it and re-flattens. An
* explicit mark rather than a peek into the parse cache, because a message with memory notes is
* warmed as its *parts*: nothing ever parses its full text, and inferring readiness from the
* cache left exactly that message unsplittable forever, re-warmed on every fold.
* message and the flatten runs on the composing thread, so a reply not marked yet stays whole
* until the screen has warmed it. An explicit mark rather than a peek into the parse cache,
* because a message with memory notes is warmed as its *parts*: nothing ever parses its full
* text, and inferring readiness from the cache left exactly that message unsplittable forever.
*/
fun splitReady(text: String): Boolean = text in ready
@@ -665,9 +621,8 @@ class ParsedReplies {
}
/**
* [code] coloured for [language] -- the answer made ahead, or one made now.
*
* The key carries the language, because the same code lexes differently under two of them.
* [code] coloured for [language] -- the answer made ahead, or one made now. The key carries the
* language, because the same code lexes differently under two of them.
*/
fun highlighted(code: String, language: Language?): AnnotatedString =
if (language == null) AnnotatedString(code)
@@ -683,9 +638,9 @@ class ParsedReplies {
*
* Suspending, and yielding between messages, because "off the composing thread" is not the same
* as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in
* a twelve second scroll, measured on a Pixel 9 Pro XL -- and on the default dispatcher that is
* every core busy, with the frame's own thread waiting for one. That showed up as 21ms of
* `waited` at the 90th percentile: the frame could not start, rather than taking too long.
* a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every
* core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th
* percentile.
*/
suspend fun warm(texts: List<String>) {
texts.forEach { text ->
@@ -694,9 +649,8 @@ class ParsedReplies {
DebugStats.timed("markdown warmed") { parseMarkdown(it) }
}
// The fences too, and here rather than in a pass of its own: they are found in the
// parse this just made, and lexing one is the same kind of cost as parsing the
// message it is in -- proportional to what was written, and charged to the frame
// that first draws it if nobody paid it earlier.
// parse this just made, and lexing one is the same kind of cost as parsing the message
// it is in.
fences(parse).forEach { (code, language) -> highlighted(code, language) }
}
}
@@ -9,7 +9,10 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.node.Ref
@@ -41,27 +44,22 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
*
* Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable,
* hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one of those
* annotations per link. Measured on the emulator against the same paragraphs with each link
* replaced by its label and address as plain words -- *more* text, the same gestures -- the linked
* version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a
* Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it.
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation
* per link. Measured on the emulator against the same paragraphs with each link replaced by its
* label and address as plain words -- *more* text, the same gestures -- the linked version cost
* five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time.
*
* Here a link is the link colour and underline, a string annotation carrying its address, and one
* tap detector for the whole text that asks the layout which character was under the finger. What
* that gives up is a link being its own accessibility node with a pressed state; the app's link
* style never defined a pressed style, so nothing visible changes.
*
* Every block the renderer dispatches through its component table comes here, which includes the
* paragraphs inside lists, quotes and alerts, and so does every table cell through
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
* resolves those against its definitions.
* Every block the renderer dispatches through its component table comes here, and so does every
* table cell. Reference-style links are the one kind still drawn the renderer's way.
*
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
* model put something, with no sign of what fell out. The link says what was there and where, and
* opens it. It also means no paragraph needs the renderer's own text composable, which existed to
* place inline images and charged every paragraph for the possibility.
* model put something. The link says what was there and where, and opens it.
*/
@Composable
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
@@ -71,8 +69,7 @@ fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
/**
* A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
* `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
* does not know, so handed the heading node itself it draws an empty line. Which is what this did
* for a week.
* does not know, so handed the heading node itself it draws an empty line.
*/
@Composable
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
@@ -96,21 +93,32 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour.
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) }
val chipColor = LocalMarkdownColors.current.inlineCodeBackground
// Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame
// finds it set -- no state needed, and a relayout redraws the node anyway.
val chipFills = remember { Ref<List<Rect>>() }
val chipFill =
if (chips.isEmpty()) Modifier
else
Modifier.drawBehind {
chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) }
}
BasicText(
text = text,
modifier =
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the
// second one has to be answered from inside the text rather than left to the card.
modifier.pointerInput(text, onPlainTap) {
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second
// one has to be answered from inside the text rather than left to the card.
modifier.then(chipFill).pointerInput(text, onPlainTap) {
awaitEachGesture {
// Unconsumed is not required: something outside may already be tracking this
// press, and it is still the press that may land on a link.
awaitFirstDown(requireUnconsumed = false)
// A tap and nothing else. Null when the gesture became something somebody
// else's -- a scroll, or a press held past the long-press timeout, which is
// how a selection starts. The timeout is the load-bearing half: without it a
// press held for a second and released was still an up with nothing consumed,
// so holding a peer message to select from it shut the card instead.
// A tap and nothing else. Null when the gesture became somebody else's -- a
// scroll, or a press held past the long-press timeout, which is how a selection
// starts. The timeout is the load-bearing half: without it a press held for a
// second and released was still an up with nothing consumed, so holding a peer
// message to select from it shut the card instead.
val up =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
@@ -130,7 +138,10 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
},
style = style,
color = { color },
onTextLayout = { layout.value = it },
onTextLayout = {
layout.value = it
chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) }
},
)
}
@@ -139,27 +150,24 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
* usually -- or null where a plain tap means nothing.
*
* A composition local because there is nowhere else to put it. The paragraphs of a message are
* composed by the renderer's own dispatch out of its component table, so nothing between a card and
* the text inside it is ours to pass a parameter through; the renderer already hands its colours,
* its typography and its components down the same way.
* composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours
* to pass a parameter through.
*
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click
* handler never sees it. Measured on the emulator against an opened peer message: with a handler on
* the text -- consuming or not -- a tap on its words did nothing at all, and with the handler
* removed entirely the same tap shut the card. So a card whose body is markdown cannot be shut by
* pressing its words unless the words do the shutting, and "nothing happens when I press it" is
* indistinguishable from a card that has stopped working.
* handler never sees it. Measured against an opened peer message: with a handler on the text --
* consuming or not -- a tap on its words did nothing at all, and with the handler removed the same
* tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless
* the words do the shutting.
*
* Provided as a value that outlives a recomposition (see [rememberMarkdownTap]), since a fresh
* lambda per composition would invalidate every paragraph reading it.
* Provided as a value that outlives a recomposition, since a fresh lambda per composition would
* invalidate every paragraph reading it.
*/
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* [onTap] as a stable value to provide for [LocalMarkdownTap].
*
* The identity stays put while the behaviour follows the latest [onTap], which is what keeps
* providing it from invalidating the text under it on every recomposition of the card.
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
* under it on every recomposition of the card.
*/
@Composable
fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit {
@@ -188,15 +196,80 @@ private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset):
private const val LINK_URL = "url"
/**
* The renderer's annotator settings with [appendPlainLink] answering for links. The annotator needs
* the settings to draw a link's label, and the settings hold the annotator, so the reference goes
* through a cell filled in once both exist.
* Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does,
* but with no background of its own -- if it is a code span; false leaves anything else to the
* renderer.
*
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
* background is part of the text's own drawing, and the text node draws the selection first and the
* glyphs over it, so a chip painted as a span background covered the selection: selecting a
* sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the
* text is under both, which is where a fenced block's box already is.
*/
private fun appendCodeChip(
builder: AnnotatedString.Builder,
content: String,
node: ASTNode,
settings: AnnotatorSettings,
): Boolean {
if (node.type != MarkdownElementTypes.CODE_SPAN) return false
builder.pushStringAnnotation(CODE_CHIP, "")
builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified))
builder.append(' ')
// The backticks are the first and last children.
builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings)
builder.append(' ')
builder.pop()
builder.pop()
return true
}
private const val CODE_CHIP = "code"
/**
* One box per line of the text [start] until [end] covers, in the layout's own coordinates.
*
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
* stops being drawn -- the same rule the selection rectangle obeys, so the two agree.
*
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
* line reads in one direction; mixed directions inside a code span would draw one box across the
* whole run, and code spans are code.
*/
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
val rects = mutableListOf<Rect>()
for (line in getLineForOffset(start)..getLineForOffset(end - 1)) {
val from = maxOf(start, getLineStart(line))
val to = minOf(end, getLineEnd(line, visibleEnd = true))
if (from >= to) continue
val head = getBoundingBox(from)
val tail = getBoundingBox(to - 1)
rects +=
Rect(
left = minOf(head.left, tail.left),
top = minOf(head.top, tail.top),
right = maxOf(head.right, tail.right),
bottom = maxOf(head.bottom, tail.bottom),
)
}
return rects
}
/**
* The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip]
* for inline code. The annotator needs the settings to draw a link's label, and the settings hold
* the annotator, so the reference goes through a cell filled in once both exist.
*/
@Composable
private fun plainLinkSettings(): AnnotatorSettings {
val cell = remember { Ref<AnnotatorSettings>() }
val annotator = remember {
markdownAnnotator { content, node -> appendPlainLink(this, content, node, cell.value!!) }
markdownAnnotator { content, node ->
appendPlainLink(this, content, node, cell.value!!) ||
appendCodeChip(this, content, node, cell.value!!)
}
}
return annotatorSettings(annotator = annotator).also { cell.value = it }
}
@@ -34,22 +34,18 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
*
* The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
* is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
* hundred short ones; and the list composes an item whole in the frame it scrolls into, so an item
* has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still
* being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a
* table, one bullet: bounded, so both costs are.
* hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on
* a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one
* message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are.
*
* Cut where the parser says the blocks are, which is the whole reason this is safe: a fence, a
* table and a nested list are each one node whatever is inside them, so nothing is ever split down
* the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty
* items -- so it is cut once more, into its items, and a nested list stays inside the item that
* holds it.
* Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a
* nested list are each one node whatever is inside them. A list is the one block that is not
* bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its
* items.
*
* A piece is an *address* into the message's one parse ([block] indexes the root's children, [item]
* the list items of that child) rather than a substring of the message. Every piece of a message is
* drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a
* reference definition at its foot still resolves the links above it -- the two costs of cutting a
* message into strings and parsing each on its own.
* A piece is an *address* into the message's one parse rather than a substring of it. Every piece
* is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and
* a reference definition at its foot still resolves the links above it.
*/
@Immutable
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
@@ -59,8 +55,7 @@ data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
}
/**
* The pieces of [parse], in reading order. Blank nodes between blocks -- the parser keeps the
* newlines -- are not pieces.
* The pieces of [parse], in reading order. Blank nodes between blocks are not pieces.
*
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
* message that drew as nothing would be a hole in the transcript with no sign of what fell out.
@@ -90,17 +85,15 @@ fun gapBefore(previous: Piece?, piece: Piece): Dp =
val BLOCK_SPACING: Dp = 6.dp
/**
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which is what carries the
* theme, the components and the reference links to the renderer's element composables.
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme,
* the components and the reference links to the renderer's element composables.
*
* A whole block goes to the renderer's own dispatch with this app's component table, so a paragraph
* or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here
* through [MarkdownList]. Only the list item is drawn directly, because a list item is the one
* piece the renderer has no element for.
* A whole block goes to the renderer's own dispatch with this app's component table. Only the list
* item is drawn directly, because a list item is the one piece the renderer has no element for.
*
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply (see
* `LiveParse`): an item that is the first or last of its own parse but not of the list the reader
* sees keeps an inner item's padding, so nothing moves when the seam between segments does.
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an
* item that is the first or last of its own parse but not of the list the reader sees keeps an
* inner item's padding, so nothing moves when the seam between segments does.
*/
@Composable
fun MarkdownPiece(
@@ -112,8 +105,8 @@ fun MarkdownPiece(
listContinues: Boolean = false,
) {
if (parse !is State.Success) {
// The parser threw. Nothing else in the app has seen this happen; if it does, the words
// are still worth more than a blank.
// The parser threw. Nothing else in the app has seen this happen; if it does, the words are
// still worth more than a blank.
Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
return
}
@@ -144,8 +137,7 @@ fun MarkdownPiece(
/**
* A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
* list inside a quote, and the nested lists an item holds. Top-level lists never come here; they
* are drawn an item at a time as pieces.
* list inside a quote, and the nested lists an item holds. Top-level lists never come here.
*/
@Composable
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
@@ -170,9 +162,8 @@ fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier
* list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
* and last items, since there is no list column to carry it.
*
* The marker is the renderer's bullet and number, and a checkbox for a task item. It is drawn here
* rather than by a handler because it is the thing a reader might one day want styled -- a
* different glyph per depth, a colour -- and this is the one place it is drawn.
* The marker is drawn here rather than by a handler because it is the thing a reader might one day
* want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn.
*/
@Composable
private fun MarkdownListItem(
@@ -231,8 +222,8 @@ private fun Marker(text: String, style: TextStyle) {
/**
* The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a
* browser draws, so a nested list is told from its parent by the glyph as well as by the indent.
* Checked on the emulator's system fonts, which is what makes them safe to rely on; a glyph the
* platform lacks draws as a box, and that check is the price of adding one here.
* Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check
* is the price of adding one here.
*/
private val BULLETS = listOf("", "", "")
@@ -0,0 +1,450 @@
package com.example.aiapp
/**
* Markdown read into the spans that carry a colour -- a ```markdown fence in a reply, and a `.md`
* file in the viewer.
*
* Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings:
* what a character means depends on where it sits. A `#` opens a heading at the start of a line and
* is an ordinary character three words in; a `*` opens emphasis only if something closes it on the
* same line. The token scanner cannot ask either question.
*
* Structure is read a line at a time and each line's prose left to right, so every decision is made
* inside one line -- except the two that are not. A fenced block is state carried forward, so an
* unclosed fence colours the rest of the text, which is what it looks like while somebody is
* writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot
* be anything else, and its header is the line before that -- the one place here that looks ahead.
*
* What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is
* one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by
* what came before. Colouring the wrong one as code is a mistake the reader cannot see.
*
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction.
*/
fun scanMarkdown(code: String): List<Span> = MarkdownScanner(code).run()
/** The characters an unordered list may be bulleted with. */
private const val BULLETS = "-*+"
/** The characters a thematic break, or a setext heading's underline, can be drawn with. */
private const val RULE_MARKERS = "-*_="
/** The characters that can open emphasis, strong emphasis or a strikethrough. */
private const val EMPHASIS = "*_~"
/** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */
private const val URL_STOPS = "<>\"'`|"
private const val URL_TRAILING = ".,:;!?"
private class MarkdownScanner(private val code: String) {
private val spans = ArrayList<Span>()
fun run(): List<Span> {
var at = 0
// The delimiter run that opened the fenced block we are inside, or null between them.
var fence: String? = null
// Whether the row above was part of a table, which is what makes this one a body row.
var table = false
while (at <= code.length) {
val end = lineEnd(at)
val open = fence
if (open != null) {
// The content and the closing line alike: a fence is one block of code, and its own
// delimiters belong to it the way a string's quotes belong to the string.
emit(at, end, Kind.STRING)
if (closesFence(at, end, open)) fence = null
} else {
val opened = opensFence(at, end)
fence = opened
if (opened != null) table = false else table = row(at, end, table)
}
if (end == code.length) break
at = end + 1
}
return spans
}
/** The end of the line beginning at [at]: the newline, or the end of the text. */
private fun lineEnd(at: Int): Int {
val newline = code.indexOf('\n', at)
return if (newline < 0) code.length else newline
}
/**
* One line that is not inside a fence, and whether the table it may be part of is still open.
*
* A table is recognised by its delimiter row, the only line of one that cannot be anything
* else. That row comes *after* the header it belongs to, so the header is found by looking one
* line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the
* document, which would mark the pipes in a shell command written in a paragraph.
*/
private fun row(start: Int, end: Int, table: Boolean): Boolean {
if (tableDelimiter(start, end)) {
emit(indented(start, end), end, Kind.MARK)
return true
}
val header = end < code.length && tableDelimiter(end + 1, lineEnd(end + 1))
if ((table || header) && hasPipe(start, end)) {
tableRow(start, end)
return true
}
structure(start, end)
return false
}
/** A line of nothing but pipes, dashes, alignment colons and space, with one of each needed. */
private fun tableDelimiter(start: Int, end: Int): Boolean {
var dashes = false
var pipes = false
for (at in indented(start, end) until end) {
when (code[at]) {
'-' -> dashes = true
'|' -> pipes = true
':',
' ',
'\t' -> {}
else -> return false
}
}
return dashes && pipes
}
private fun hasPipe(start: Int, end: Int): Boolean {
var at = start
while (at < end) {
if (code[at] == '\\') at += 2 else if (code[at] == '|') return true else at++
}
return false
}
/** A table row: the pipes are the structure, and what is between them is prose. */
private fun tableRow(start: Int, end: Int) {
var at = indented(start, end)
var cell = at
while (at < end) {
when (code[at]) {
'\\' -> at += 2
'|' -> {
inline(cell, at)
emit(at, at + 1, Kind.MARK)
at++
cell = at
}
else -> at++
}
}
inline(cell, end)
}
/**
* Spans, coalesced with the one before when they touch and agree. Worth doing here rather than
* leaving it to the caller: the line scanner emits per marker and per word, so a heading would
* otherwise arrive as a dozen abutting spans of one colour.
*/
private fun emit(start: Int, end: Int, kind: Kind) {
if (end <= start) return
val last = spans.lastOrNull()
if (last != null && last.kind == kind && last.end == start) {
spans[spans.size - 1] = Span(last.start, end, kind)
} else {
spans.add(Span(start, end, kind))
}
}
/** The first character of the line at or after [start] that is not indentation. */
private fun indented(start: Int, end: Int): Int {
var at = start
while (at < end && (code[at] == ' ' || code[at] == '\t')) at++
return at
}
/** The run of backticks or tildes that could open or close a fence on this line, or null. */
private fun fenceRun(start: Int, end: Int): IntRange? {
val at = indented(start, end)
if (at == end) return null
val marker = code[at]
if (marker != '`' && marker != '~') return null
var run = at
while (run < end && code[run] == marker) run++
return if (run - at >= 3) at until run else null
}
/** Draws an opening fence line and answers its delimiter, or null if this is not one. */
private fun opensFence(start: Int, end: Int): String? {
val run = fenceRun(start, end) ?: return null
emit(run.first, run.last + 1, Kind.STRING)
// The info word is what the fence is a fence *of*, which is metadata about the block rather
// than part of it.
emit(indented(run.last + 1, end), end, Kind.METADATA)
return code.substring(run.first, run.last + 1)
}
/**
* Whether this line closes a fence opened by [open]: the same character, at least as many of
* them, and nothing else on the line -- so a longer run closes a shorter one and a line of
* backticks with a word after it does not close anything.
*/
private fun closesFence(start: Int, end: Int, open: String): Boolean {
val run = fenceRun(start, end) ?: return false
if (code[run.first] != open[0] || run.last + 1 - run.first < open.length) return false
return indented(run.last + 1, end) == end
}
/** One ordinary line: what its opening characters make it, and then its prose. */
private fun structure(start: Int, end: Int) {
var at = indented(start, end)
// Quote markers come before everything else and can be several deep, and what follows one
// is an ordinary line again -- a heading inside a quote is still a heading.
while (at < end && code[at] == '>') {
at++
emit(at - 1, at, Kind.MARK)
at = indented(at, end)
}
if (at == end) return
if (heading(at, end) || thematicBreak(at, end)) return
inline(bullet(at, end), end)
}
/** `#` to `######` and a space. Without the space it is a word beginning with a hash. */
private fun heading(start: Int, end: Int): Boolean {
var at = start
while (at < end && code[at] == '#') at++
val depth = at - start
if (depth !in 1..6) return false
if (at < end && code[at] != ' ' && code[at] != '\t') return false
emit(start, end, Kind.KEYWORD)
return true
}
/**
* A line made of one repeated rule character and nothing else.
*
* `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a
* setext heading. The two are the same line to look at and mean the same thing to a reader, so
* they get one appearance rather than a lookback. One `=` is enough because a setext underline
* may be a single character; a break needs three, which keeps a `- ` bullet out of here.
*/
private fun thematicBreak(start: Int, end: Int): Boolean {
val marker = code[start]
if (marker !in RULE_MARKERS) return false
var seen = 0
for (at in start until end) {
val character = code[at]
if (character == marker) seen++ else if (!character.isWhitespace()) return false
}
if (seen < if (marker == '=') 1 else 3) return false
emit(start, end, Kind.MARK)
return true
}
/** Draws a list marker if the line opens with one, and answers where the item's text starts. */
private fun bullet(start: Int, end: Int): Int {
val marker = code[start]
if (marker in BULLETS && spaceOrEnd(start + 1, end)) {
emit(start, start + 1, Kind.MARK)
return indented(start + 1, end)
}
var digits = start
while (digits < end && code[digits].isDigit()) digits++
val delimiter = code.getOrNull(digits)
if (
digits > start && (delimiter == '.' || delimiter == ')') && spaceOrEnd(digits + 1, end)
) {
emit(start, digits + 1, Kind.MARK)
return indented(digits + 1, end)
}
return start
}
private fun spaceOrEnd(at: Int, end: Int) = at >= end || code[at] == ' ' || code[at] == '\t'
/**
* The inline forms, left to right.
*
* Every branch answers a position strictly after [start] of its call, so this terminates
* whether or not the form it was looking at turned out to be one.
*/
private fun inline(start: Int, end: Int) {
var at = start
while (at < end) {
val character = code[at]
at =
when {
// A backslash takes the character after it out of the running entirely, which
// is how `\*` stays an asterisk rather than opening emphasis.
character == '\\' -> at + 2
character == '`' -> codeSpan(at, end)
character == '[' -> link(at, at, end)
character == '!' && code.getOrNull(at + 1) == '[' -> link(at, at + 1, end)
character == '<' -> autolink(at, end)
character in EMPHASIS -> emphasis(at, end)
else -> url(at, end) ?: (at + 1)
}
}
}
/**
* `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what
* lets a span hold a backtick of its own, and why the search skips over a shorter or longer run
* rather than stopping at the first backtick.
*/
private fun codeSpan(start: Int, end: Int): Int {
var open = start
while (open < end && code[open] == '`') open++
val ticks = open - start
var at = open
while (at < end) {
if (code[at] != '`') {
at++
continue
}
var close = at
while (close < end && code[close] == '`') close++
if (close - at == ticks) {
emit(start, close, Kind.STRING)
return close
}
at = close
}
// Nothing closes it on this line, so those were ordinary backticks.
return open
}
/**
* `[text](destination)`, and the same with a leading `!` for an image.
*
* The text is drawn as prose -- it is what the reader reads -- so only the brackets around it
* are marked, and the destination is metadata. A `[text]` with no destination after it is left
* plain, because that is what a reference link and a bracketed aside look like.
*/
private fun link(start: Int, bracket: Int, end: Int): Int {
var depth = 0
var close = bracket
while (close < end) {
when (code[close]) {
'\\' -> close++
'[' -> depth++
']' -> {
depth--
if (depth == 0) break
}
}
close++
}
if (close >= end) return start + 1
val destination = close + 1
if (code.getOrNull(destination) != '(') return start + 1
val paren = code.indexOf(')', destination)
if (paren < 0 || paren >= end) return start + 1
emit(start, bracket + 1, Kind.MARK)
inline(bracket + 1, close)
emit(close, destination, Kind.MARK)
emit(destination, paren + 1, Kind.METADATA)
return paren + 1
}
/**
* `<https://example.com>` and `<name@example.com>`, drawn as the destination they are.
*
* The angle brackets have to hold no whitespace and something that makes an address of it -- a
* scheme's colon or an at sign -- which is what keeps an HTML tag out.
*/
private fun autolink(start: Int, end: Int): Int {
var at = start + 1
var addressed = false
while (at < end) {
val character = code[at]
if (character.isWhitespace() || character == '<') return start + 1
if (character == '>') {
if (!addressed) return start + 1
emit(start, at + 1, Kind.METADATA)
return at + 1
}
if (character == ':' || character == '@') addressed = true
at++
}
return start + 1
}
/**
* A bare `scheme://…` written in prose, or null if one does not start here.
*
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry.
*
* Where it ends is the part worth stating: the sentence's punctuation is not the address, so a
* trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the
* URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a
* table cell must not swallow the cell's edge.
*/
private fun url(start: Int, end: Int): Int? {
if (start > 0 && isWord(code[start - 1])) return null
var scheme = start
while (scheme < end && code[scheme].isLetter()) scheme++
if (scheme == start || !code.startsWith("://", scheme)) return null
val body = scheme + 3
var at = body
var openers = 0
var closers = 0
while (at < end && !code[at].isWhitespace() && code[at] !in URL_STOPS) {
if (code[at] == '(') openers++ else if (code[at] == ')') closers++
at++
}
while (at > body) {
val last = code[at - 1]
if (last in URL_TRAILING) at--
else if (last == ')' && closers > openers) {
closers--
at--
} else break
}
if (at == body) return null
emit(start, at, Kind.METADATA)
return at
}
/**
* `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the
* token scanner draws a string: the quotes are part of the thing.
*
* The two guards keep this off code that happens to be in a paragraph: the opener must be
* followed by something to emphasise and the closer preceded by something emphasised, so `a * b
* * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start
* or end inside a word, or every `snake_case_name` would be half emphasised.
*/
private fun emphasis(start: Int, end: Int): Int {
val marker = code[start]
var open = start
while (open < end && code[open] == marker) open++
val length = open - start
if (marker == '~' && length != 2) return open
if (length > 3) return open
if (open == end || code[open].isWhitespace()) return open
if (marker == '_' && start > 0 && isWord(code[start - 1])) return open
var at = open
while (at < end) {
if (code[at] == '\\') {
at += 2
continue
}
if (code[at] != marker) {
at++
continue
}
var close = at
while (close < end && code[close] == marker) close++
val finish = at + length
if (
close - at >= length &&
!code[at - 1].isWhitespace() &&
!(marker == '_' && finish < end && isWord(code[finish]))
) {
emit(start, finish, Kind.LITERAL)
return finish
}
at = close
}
return open
}
}
private fun isWord(character: Char) = character.isLetterOrDigit() || character == '_'
@@ -25,12 +25,11 @@ import androidx.compose.ui.unit.dp
* Claude Code marks a sentence that came from its stored memory by wrapping it in `<cc-memory
* filenames="...">`. Markdown has nothing to say about that, so it arrived on screen as literal
* angle brackets in the middle of a sentence -- which reads as the model having emitted broken
* HTML. It is really the opposite: a claim about where something came from, which is worth showing,
* because "I was told this before" and "I worked this out just now" are different things and the
* reader cannot otherwise tell them apart.
* HTML. It is really the opposite: a claim about where something came from, and "I was told this
* before" and "I worked this out just now" are different things the reader cannot otherwise tell
* apart.
*
* A tag that has not finished arriving is left alone. Streaming means the closing tag may be
* seconds away, and a half-written marker is not a marker yet.
* A tag that has not finished arriving is left alone: a half-written marker is not a marker yet.
*/
@Composable
fun AssistantMessage(
@@ -66,12 +65,10 @@ fun AssistantMessage(
* A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed
* prose part made while looking for them -- inspecting a message must not change it. That belongs
* here rather than at the places that need the answer, because [warm] has to name the same strings
* the rows draw: a string warmed under a key no row ever looks up is a miss that nothing reports,
* and the row pays the parse in the frame it appears, which is the cost being removed.
* the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports.
*
* Public because [transcriptUnits] flattens settled replies into the same parts; go through
* [ParsedReplies.partsOf] on any path that runs per fold or per page, so the scan happens once per
* message.
* [ParsedReplies.partsOf] on any path that runs per fold or per page.
*/
fun messageParts(text: String): List<MessagePart> {
val parts = splitMemoryNotes(text)
@@ -83,16 +80,14 @@ fun messageParts(text: String): List<MessagePart> {
*
* Closed by default, like a tool call and a peer message and for the same reason: it is not part of
* what was said to the reader, it is a note about where a claim came from. Left open it breaks the
* reply in half around a card, which reads as the answer having stopped and restarted -- and these
* arrive several to a message.
* reply in half around a card, and these arrive several to a message.
*
* What stays visible is which file it came from, because that is the whole of what the note claims
* and it is the part a reader scanning for "why does it think that" is looking for.
* and the part a reader scanning for "why does it think that" is looking for.
*
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
* still be open on the way back, and a card that remembered for itself would forget the moment the
* list stopped composing it. The text is a good enough name -- it does not change once the closing
* tag has arrived, so a note stays open across the moment its reply settles.
* list stopped composing it.
*/
@Composable
fun MemoryNote(
@@ -148,10 +143,8 @@ private val MEMORY_NOTE =
Regex("""<cc-memory\s+filenames="([^"]*)"\s*>(.*?)</cc-memory>""", RegexOption.DOT_MATCHES_ALL)
/**
* Splits [text] into prose and memory notes, in order.
*
* Always returns at least one part, so a message with no notes in it is one piece of prose and
* costs nothing extra to draw.
* Splits [text] into prose and memory notes, in order. Always returns at least one part, so a
* message with no notes is one piece of prose and costs nothing extra to draw.
*/
fun splitMemoryNotes(text: String): List<MessagePart> {
val parts = mutableListOf<MessagePart>()
@@ -5,26 +5,23 @@ package com.example.aiapp
*
* One constant rather than a literal in each place, because the two have to agree: a picker whose
* options cannot say every state its button can display is one you can leave and not get back to.
* It is also the Claude CLI's own word for "whatever is configured", so choosing it is a request
* the session can act on rather than a name this app made up.
* It is also the Claude CLI's own word for "whatever is configured".
*/
const val DEFAULT_MODEL = "default"
/**
* A model's name as a person reads it.
*
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to
* `claude-haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session
* using" and far too long for a button in a row that also has to hold Stop and Send.
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude-
* haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using"
* and far too long for a button in a row that also holds Stop and Send.
*
* So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which
* is the same on every model this app can show, and the release date, which distinguishes builds of
* one model rather than one model from another. What is left is the part somebody chose --
* `haiku-4-5` -- and anything that does not look like that is returned untouched, since a name this
* does not recognise is a name it has no business editing.
* one model rather than one model from another. Anything that does not look like that is returned
* untouched.
*
* A display decision, not a correction: the full name is what the session reports and what a reader
* is shown when there is room for it.
* A display decision, not a correction: the full name is what the session reports.
*/
fun modelLabel(model: String?): String {
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
@@ -57,12 +57,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
}
}
// Polled rather than pushed: a download belongs to the machine, not to
// any session, so it has no event stream of its own. Slow enough not
// to matter, frequent enough that a bar moves.
// Keyed on the token as well, so the header's Refresh restarts the loop with a read now
// rather than leaving the reader watching for up to a second and a half to see whether
// anything happened.
// Polled rather than pushed: a download belongs to the machine, not to any session, so it has
// no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the
// loop with a read now rather than leaving the reader watching for a second and a half.
LaunchedEffect(reloadToken) {
while (true) {
reload()
@@ -186,11 +183,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
}
}
}
// Inside the expanded repository's own item
// rather than as a section after the list:
// drawn after every card, a repository's files
// read as belonging to whichever card happened
// to be last.
// Inside the expanded repository's own item rather than as a section
// after the list: drawn after every card, a repository's files read as
// belonging to whichever card happened to be last.
if (open) {
when (val files = repoFiles) {
null -> {}
@@ -257,15 +252,15 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. The server
// sends no total when it was never told one, and a bar drawn
// from a guess is worse than one that admits it is counting.
// A determinate bar only when the size is known. The server sends no total when it was
// never told one, and a bar drawn from a guess is worse than one that admits it is
// counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say
// the opposite of what is happening.
// nearing success, and colouring it like a limit being approached would say the
// opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
@@ -327,8 +322,8 @@ private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at
// the end is what tells two entries apart.
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
@@ -356,11 +351,9 @@ private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether
// this one is absent, already here, or on its way. Offering
// "Download" for a file that is downloading would be a button that
// does nothing anyone can see -- the server joins the running
// download rather than starting a second.
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
@@ -26,26 +26,21 @@ import androidx.compose.ui.unit.sp
* set and kept in step by hand.
*
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
* grounds that a system font may not have the glyph and whoever gets the empty box instead is never
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
* right: the answer is not to avoid glyphs but to ship them. The font here is
* `app/build-icon-font.sh`'s output -- eleven glyphs, 2.1 KB, subset out of the 3 MB symbols font
* and committed -- so the codepoints below are resolved by an asset in the APK and cannot come back
* as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
* did not subset is a glyph that silently isn't there.
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
* is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
* the script did not subset is a glyph that silently isn't there.
*
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
* That is what makes two icons the same size without either of them being given a size: the
* proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side
* by side came out visibly different widths, and matching them at the call site would have meant
* one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost.
* That is what makes two icons the same size without either being given a size: the proportional
* face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came
* out visibly different widths. [GLYPH_SIZE] carries the cost.
*
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app
* and something else in the other is the failure this is worth preventing. The script is copied
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to
* differ -- the point of subsetting is to ship only the codepoints one app draws. All Material
* Design bar one, so they read as one family; the exception is noted where it is declared.
* Material Design codepoints. Those two must not drift. The script is copied rather than shared
* because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the
* point of subsetting is to ship only the codepoints one app draws.
*/
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
@@ -74,8 +69,7 @@ val STOP_GLYPH = glyph(0xF04DB)
*
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
* pressing it now would do to the process, and the three marks are the three answers. An interrupt
* ends a turn and nothing else -- the CLI is still there and still holds the conversation -- which
* is a pause, not a stop, and drawing it as a square said otherwise.
* ends a turn and nothing else, which is a pause, not a stop.
*/
val PAUSE_GLYPH = glyph(0xF03E4)
@@ -86,8 +80,8 @@ val PLAY_GLYPH = glyph(0xF040A)
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
*
* The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than
* starting one, and the two buttons have to be told apart at a glance -- one glyph doing both jobs
* while looking identical would promise something immediate and do something that waits.
* starting one, and one glyph doing both jobs would promise something immediate and do something
* that waits.
*/
val QUEUE_GLYPH = glyph(0xF1163)
@@ -104,8 +98,7 @@ val BELL_GLYPH = glyph(0xF009A)
* `fa-line_chart` -- how much of the account's rate limits is gone.
*
* Font Awesome's rather than Material's, which is the one break in the family above: it was asked
* for by name, and Material's chart glyphs are a bare line where this one has its axes, which is
* what makes it read as a measurement rather than as a trend.
* for by name, and Material's chart glyphs are a bare line where this one has its axes.
*/
val USAGE_GLYPH = glyph(0xF201)
@@ -117,14 +110,39 @@ val USAGE_GLYPH = glyph(0xF201)
*/
val SPEED_GLYPH = glyph(0xF04C5)
/**
* `md-folder` -- the files on the machine this session runs on.
*
* The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and
* the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is
* what makes the button say where it leads.
*/
val FOLDER_GLYPH = glyph(0xF024B)
/** `md-file_outline` -- one file, in a listing beside the directories. */
val FILE_GLYPH = glyph(0xF0224)
/** `md-plus` -- make something here. dev-updater's codepoint as well. */
val PLUS_GLYPH = glyph(0xF0415)
/** `md-pencil` -- change what this file says, rather than only reading it. */
val EDIT_GLYPH = glyph(0xF03EB)
/**
* `md-content_save` -- write the edits back to the machine.
*
* The floppy disk, which is what save has meant for longer than most of the people reading it have
* been alive and is still the only mark anybody recognises for it.
*/
val SAVE_GLYPH = glyph(0xF0193)
/**
* The size an icon draws at beside a line of text.
*
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
* most 0.83 em of its point size and most filled a good deal less, so the number was standing in
* for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and
* keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked
* for. This is what the largest of them already drew at.
* most 0.83 em of its point size, so the number was standing in for the headroom above the tallest
* one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every
* icon in the app up by a fifth.
*/
private val GLYPH_SIZE = 17.sp
@@ -138,16 +156,14 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
*
* The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to
* the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of
* its own, and a mark cannot end up further from the button beside it than from the edge of the
* screen. That is what it was: the box was the size of the mark (28dp) and the separation was
* its own. That is what it was: the box was the size of the mark (28dp) and the separation was
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
* edge, so a pair that acts on one screen read as two unrelated marks with one falling off it.
* edge.
*
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
* to find. It is what the pressed-state ripple draws, too: at 28dp that circle was inscribed in the
* mark's own corners, and beside a title it arrived at the first letter. And it is taller than any
* header's text, which is what lets the button fill a header row rather than sit in the middle of
* one -- the rows add no vertical padding of their own for the same reason they add no gap.
* to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
* text, which is what lets the button fill a header row rather than sit in the middle of one.
*/
private val GLYPH_BUTTON_SIZE = 48.dp
@@ -155,11 +171,9 @@ private val GLYPH_BUTTON_SIZE = 48.dp
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
* a back arrow.
*
* Two glyph buttons need nothing between them: each brings its own ring and the two add up, which
* is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for.
* Without it the pressed-state circle, which fills the whole square, arrives at the first letter of
* the title -- and the gap a reader sees between the mark and that title is then half the one
* between the two marks at the other end of the same row.
* Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text
* brings none, so the second ring has to be asked for -- without it the pressed-state circle
* arrives at the first letter of the title.
*/
val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
@@ -168,8 +182,7 @@ val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
*
* Its own composable so that every icon button in the app is one size and one colour without each
* caller saying so, and so the [label] none of them displays is still there for a screen reader --
* which is all assistive technology has to go on, and also the answer to "what was that button for"
* six months from now.
* which is also the answer to "what was that button for" six months from now.
*
* [enabled] is passed through rather than left to callers hiding the button: a control that comes
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
@@ -193,9 +206,8 @@ fun GlyphButton(
* The same square, around a mark that is not a glyph.
*
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
* size, spacing and touch target every other icon button on this app's headers already is -- so
* this is [GlyphButton] with the mark left to the caller rather than a second set of measurements
* beside it. The caller still owes it a [label]: nothing here draws a word.
* size, spacing and touch target every other icon button already is. The caller still owes it a
* [label]: nothing here draws a word.
*/
@Composable
fun MarkButton(
@@ -218,9 +230,7 @@ fun MarkButton(
* The square a glyph button occupies, with a spinner in it instead of a mark.
*
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so
* swapping one for the other leaves everything in the row exactly where it was -- a control that
* changed the width of its header while it worked would move its neighbours at the moment somebody
* was pressing them.
* swapping one for the other leaves everything in the row exactly where it was.
*/
@Composable
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
@@ -246,10 +256,9 @@ fun Glyph(
size: TextUnit = GLYPH_SIZE,
) {
// Line height of the point size, which for this font is the square the glyph draws in: its
// ascent and descent add up to exactly one em, and every glyph in the Mono face fills that em.
// Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a
// glyph took a seventh more vertical space than horizontal wherever one is drawn without a box
// around it -- and where there is a box, that leading is what its padding is measured through.
// ascent and descent add up to exactly one em. Left to the inherited body style the line box
// was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than
// horizontal.
Text(
glyph,
fontFamily = NerdIcons,
@@ -34,12 +34,11 @@ import org.json.JSONObject
* gets a push from Google's servers, which would mean this backend talking to Google about
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
*
* The cost Android charges for it is a notification of its own that cannot be dismissed. That is
* made as quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no
* sound, shows no status-bar icon, and sits at the bottom of the shade -- the same arrangement
* Syncthing's "hide the persistent notification" option produces. It is not hidden outright,
* because it cannot be and because it should not be: it is the honest indicator that something is
* holding a connection open.
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
* cannot be and because it should not be: it is the honest indicator that something is holding a
* connection open.
*/
class NotificationService : Service() {
@Volatile private var stream: HttpURLConnection? = null
@@ -50,18 +49,18 @@ class NotificationService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val settings = loadServerSettings(this)
if (settings == null) {
// Nothing to connect to. Stopping rather than idling: a service
// holding no connection still costs the ongoing notification,
// which would then be announcing work that is not happening.
// Nothing to connect to. Stopping rather than idling: a service holding no connection
// still costs the ongoing notification, which would be announcing work that is not
// happening.
stopSelf()
return START_NOT_STICKY
}
// Through ServiceCompat so the type is stated once and ignored on
// the versions that predate types, rather than branching here.
// Through ServiceCompat so the type is stated once and ignored on the versions that predate
// types, rather than branching here.
ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType())
thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) }
// Restarted if Android kills it, which is the whole point: the
// window this covers is exactly the one where nobody is watching.
// Restarted if Android kills it, which is the whole point: the window this covers is
// exactly the one where nobody is watching.
return START_STICKY
}
@@ -73,11 +72,10 @@ class NotificationService : Service() {
/**
* Follows the backend's notification stream, reconnecting until stopped.
*
* A dropped connection is the ordinary case here rather than an error -- a phone changes
* networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and
* forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you
* whether anything happened" on a phone in somebody's pocket is noise about a condition they
* cannot act on, and the session list already says what is waiting when they next look.
* A dropped connection is the ordinary case here rather than an error, so it retries quietly
* and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell
* you whether anything happened" is noise about a condition nobody can act on, and the session
* list already says what is waiting when they next look.
*/
private fun follow(settings: ServerSettings) {
while (!stopping) {
@@ -102,8 +100,8 @@ class NotificationService : Service() {
try {
connection.applyPinnedTls()
connection.connectTimeout = CONNECT_TIMEOUT_MS
// No read timeout, for the reason EventStream gives: between
// notifications there is nothing to read, possibly for hours.
// No read timeout, for the reason EventStream gives: between notifications there is
// nothing to read, possibly for hours.
connection.readTimeout = 0
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
connection.setRequestProperty("Accept", "text/event-stream")
@@ -134,28 +132,23 @@ class NotificationService : Service() {
*
* Keyed by session id rather than accumulating: two sessions wanting attention are two things
* to know about, but one session that finished and then asked a question is one thing -- the
* question. A stack of stale rows for the same conversation is how a notification drawer
* becomes something to clear rather than read.
* question. A stack of stale rows is how a drawer becomes something to clear rather than read.
*/
private fun show(notification: SessionNotification) {
// Nothing to tell somebody about the session they are reading. The transcript in front of
// them is already saying it, and a sound over the top of it would be this app announcing
// what the screen is showing.
// them is already saying it.
if (isOnScreen(notification.sessionId)) return
// The app is up: it says this itself, as a banner over whatever screen they are on. See
// [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an
// app that already showed you each one is a drawer nobody reads.
// The app is up: it says this itself, as a banner over whatever screen they are on. Never
// both -- one thing happened, and a drawer filling up behind an app that already showed you
// each one is a drawer nobody reads.
if (handOver(notification)) return
val manager = NotificationManagerCompat.from(this)
// Two different noes, and both are answers rather than faults: the runtime permission
// refused, and notifications switched off for the app in Android's own settings. Neither
// is reported anywhere -- the person said no, and saying it back to them through the
// channel they closed is not available anyway.
// refused, and notifications switched off for the app in Android's own settings.
//
// The permission only exists from Android 13. Asking an older version about it gets
// "denied" for a name it does not know, which read as the person having said no -- so
// every notification on Android 12 and below was silently dropped. Before 13 the
// switch in Android's own settings, checked below, is the whole of the answer.
// "denied" for a name it does not know, which read as the person having said no -- so every
// notification on Android 12 and below was silently dropped.
val allowed =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
@@ -187,9 +180,8 @@ class NotificationService : Service() {
* The type Android 14+ requires a foreground service to declare, and nothing before it.
*
* Named behind a version check rather than passed as a constant: the value is inlined at
* compile time and would be handed to platforms that have no concept of it, which is exactly
* the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do
* not apply.
* compile time and would be handed to platforms that have no concept of it, which is what
* lint's InlinedApi exists to catch.
*/
private fun foregroundType(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
@@ -226,11 +218,10 @@ class NotificationService : Service() {
/**
* Two channels, because they are two different things to be told.
*
* The alerts are what somebody turned this on for, so they get the default importance and
* whatever sound and heads-up display the person has chosen for the app. The ongoing one is
* the platform's tax for staying connected, so it takes the lowest importance that exists.
* Both are created before the service starts, since posting to a channel that does not
* exist is silently dropped.
* The alerts are what somebody turned this on for, so they get the default importance. The
* ongoing one is the platform's tax for staying connected, so it takes the lowest
* importance that exists. Both are created before the service starts, since posting to a
* channel that does not exist is silently dropped.
*/
private fun createChannels(context: Context) {
val manager = NotificationManagerCompat.from(context)
@@ -256,12 +247,10 @@ class NotificationService : Service() {
* The session somebody is looking at, or null when no screen is showing one.
*
* Process-wide state, which the rest of this app does without: Android constructs the
* service and the composition draws the screen, so the two have no common owner a value
* could be passed through. [showing] and [stoppedShowing] are the pair, both called from
* the one composable that shows a session. Clearing names the session rather than setting
* null outright, because moving from one session to another composes the new screen before
* the old one's coroutine is cancelled -- an unconditional clear would then throw away the
* new screen's claim and start notifying about what is on it.
* service and the composition draws the screen, so the two have no common owner. Clearing
* names the session rather than setting null outright, because moving from one session to
* another composes the new screen before the old one's coroutine is cancelled -- an
* unconditional clear would throw away the new screen's claim.
*/
@Volatile private var onScreen: String? = null
@@ -271,10 +260,9 @@ class NotificationService : Service() {
* The way a notification reaches the app instead of Android's drawer.
*
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
* [SessionAlerts] collects this exactly while it is on screen, so there is nothing that
* could be left saying the app is up after it has gone. `tryEmit` neither suspends nor
* blocks the thread reading the stream, and the buffer is there so a handful of sessions
* finishing together all land rather than the last one winning.
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
* nor blocks the thread reading the stream, and the buffer is there so a handful of
* sessions finishing together all land rather than the last one winning.
*/
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
@@ -287,9 +275,8 @@ class NotificationService : Service() {
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
fun showing(context: Context, sessionId: String) {
onScreen = sessionId
// Whatever was posted about it before is about to be read, so it has nothing left
// to say -- and a row in the drawer for the conversation on screen is the same
// duplication this whole rule is about.
// Whatever was posted about it before is about to be read, so it has nothing left to
// say.
NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID)
}
@@ -311,8 +298,8 @@ class NotificationService : Service() {
* The intent that opens one session, and the id it carries back out.
*
* The two halves are written together so neither can be changed without the other, and the scheme
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look at
* when an intent arrives rather than two.
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look
* at.
*
* The id rides in the intent's **data** rather than in an extra, which is not a style choice:
* PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring
@@ -345,9 +332,8 @@ data class SessionNotification(
* What a notification asks of the reader, in the words they see.
*
* What they have to do, not what the session did: "awaitingInput" is the wire's word and says
* nothing to somebody reading a lock screen. One function because the same fact is now shown in two
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The
* banner colours the line as well, which is its own decision and stays with the drawing.
* nothing to somebody reading a lock screen. One function because the same fact is shown in two
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift.
*/
fun attentionLine(kind: String): String =
when (kind) {
@@ -31,13 +31,12 @@ import androidx.compose.ui.unit.dp
*
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
* transcript that puts it in their voice is making a claim about who asked for the work that
* follows -- which is exactly the question a peer message is usually the answer to.
* follows.
*
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for the measurements that
* bought; what matters here is that the pieces have to add up to the card that was there before, so
* the fill, the corner radius and the padding all live in [peerSurface] rather than being written
* out at each piece.
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought;
* what matters here is that the pieces have to add up to the card that was there before, so the
* fill, the corner radius and the padding all live in [peerSurface].
*/
@Composable
fun PeerHeadRow(
@@ -91,9 +90,9 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
// Without this the card closes everywhere except on the text, which is most of it.
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
// The gap the card's own column used to provide between its heading and its prose,
// and between one block and the next -- inside the piece, so the card's fill runs
// through it.
// The gap the card's own column used to provide between its heading and its prose, and
// between one block and the next -- inside the piece, so the card's fill runs through
// it.
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
}
}
@@ -102,16 +101,14 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
/**
* One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it.
*
* A filled Material card is elevation zero ([CardDefaults] takes it from `FilledCardTokens`, which
* is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason a
* card can be cut up at all. Each piece paints the caller's container colour the way a
* [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message,
* so the pieces abut into one continuous card. Shared by the two rows that are cut this way -- an
* opened peer message and a long user message -- because two copies of the corner logic is how one
* of them grows a seam.
* A filled Material card is elevation zero, so there is no shadow that a seam would show through --
* which is the whole reason a card can be cut up at all. Each piece paints the caller's container
* colour and rounds only the corners at the ends of the message, so the pieces abut into one
* continuous card. Shared by the two rows cut this way -- an opened peer message and a long user
* message -- because two copies of the corner logic is how one of them grows a seam.
*
* The padding is the other half of it: 12dp all round was the card's own, so the top piece keeps
* the top of it, the bottom piece the bottom, and the middle pieces neither.
* The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top
* of it, the bottom piece the bottom, and the middle pieces neither.
*/
@Composable
fun Modifier.cardPiece(
@@ -34,13 +34,10 @@ import androidx.compose.ui.unit.sp
* What is about to be sent, directly above the box it will be sent from.
*
* The count on the "+" button was the whole of what said an image was attached, so the only way to
* find out *which* image was to send it. A control belongs with the thing it acts on, and what
* these are attached to is the message being typed -- which is why they sit here rather than
* anywhere else on the screen.
* find out *which* image was to send it. A control belongs with the thing it acts on.
*
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
* in it, so four attachments look like four of the same thing rather than four smaller ones. A file
* is a tile of the same height carrying its name, since a name is all there is to show of it.
* in it, so four attachments look like four of the same thing rather than four smaller ones.
*/
@Composable
fun PendingAttachments(
@@ -67,8 +64,7 @@ fun PendingAttachments(
*
* Removal is here because there is nowhere else it could be: an image picked by mistake could
* otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip -- and
* the label is what says so, since nothing about the picture does.
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip.
*/
@Composable
private fun PendingThumbnail(
@@ -84,8 +80,7 @@ private fun PendingThumbnail(
.clip(shape)
// An outline as well as a fill. Most of what gets attached here is a screenshot of a
// dark app, and cropped to a square its middle is often near-black -- against this
// background the tile then had no edge at all, and the only thing saying an image was
// attached was the cross drawn on top of nothing.
// background the tile then had no edge at all.
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
// Behind the picture as well as under a missing one, so the tile is a tile before
// anything has arrived to fill it.
@@ -105,9 +100,9 @@ private fun PendingThumbnail(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// A spinner, as the transcript's images have: one appearance for "a picture
// is on its way", learned once. An ellipsis had to be read as a spinner that
// was not moving.
// A spinner, as the transcript's images have: one appearance for "a picture is
// on its way", learned once. An ellipsis had to be read as a spinner not
// moving.
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
else ->
@@ -118,14 +113,12 @@ private fun PendingThumbnail(
modifier = Modifier.size(THUMBNAIL),
)
}
// The whole square removes it, and this only says so. A cross small enough to sit in
// the corner of a 64dp thumbnail is smaller than a fingertip, so making it the target
// would be a control drawn at a size nobody can hit.
// The whole square removes it, and this only says so. A cross small enough to sit in the
// corner of a 64dp thumbnail is smaller than a fingertip.
//
// The disc is sized here and the mark centred inside it, rather than the glyph being
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box
// to the corner hung the visible mark over the edge and put its backing somewhere the
// eye reads as a second, misplaced square.
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to
// the corner hung the visible mark over the edge.
Box(
Modifier.align(Alignment.TopEnd)
.padding(2.dp)
@@ -3,15 +3,13 @@ package com.example.aiapp
import com.example.wgapplink.PinnedTls
import java.net.HttpURLConnection
// PINNED_CA_PEM is generated at build time from the CA on the machine doing
// the build -- see the generatePinnedCert task in build.gradle.kts. It is
// deliberately not a checked-in constant: the private key that signs against
// it must never be anywhere this repo is, and an APK should pin whatever CA
// the backend it was built for actually serves.
// PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the
// generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the
// private key that signs against it must never be anywhere this repo is, and an APK should pin
// whatever CA the backend it was built for actually serves.
//
// The pinning itself lives in wg-app-link, since dev-updater needs exactly
// the same thing. What stays here is the one product-specific fact -- which
// certificate this app pins.
// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What
// stays here is which certificate this app pins.
private val pinned = PinnedTls(PINNED_CA_PEM)
/** Every request this app makes goes through this -- there is no unpinned path. */
@@ -16,20 +16,17 @@ import androidx.compose.ui.unit.dp
*
* A composable rather than a modifier repeated at each site, because the inset is part of it --
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
* copies of "clip, fill, pad" drift apart the first time one of them is adjusted.
* copies of "clip, fill, pad" drift apart the first time one is adjusted.
*
* The colour is [rawSurface], which is also what a code block inside a reply is given; that is the
* point of having one name for it. Markdown's blocks are painted by the renderer rather than by
* this, since it draws its own, but they are the same colour on purpose.
* The colour is [rawSurface], which is also what a code block inside a reply is given.
*/
@Composable
fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier
.fillMaxWidth()
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a
// rounded rectangle drawn at the same radius as the rounded rectangle behind it reads
// as a misprint rather than as nesting.
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded
// rectangle drawn at the same radius as the one behind it reads as a misprint.
.clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface)
.padding(horizontal = 8.dp, vertical = 6.dp),
@@ -4,17 +4,16 @@ import java.time.Duration
import java.time.OffsetDateTime
// How long is left in a usage window. Shared by the session bar and the usage screen: the
// arithmetic is the same in both and only the sentence around it differs, so everything here
// returns the span or the state on its own and leaves the wording to the caller.
// arithmetic is the same in both, so everything here returns the span or the state on its own and
// leaves the wording to the caller.
/**
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
*
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
* figure on a minute it has already spent -- so the reader watching the number decide whether to
* start something was consistently told less headroom than they had. One rule, so the session bar
* and the usage dialog cannot round a shared measurement two different ways.
* figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot
* round a shared measurement two different ways.
*/
fun formatSpan(until: Duration): String {
val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1)
@@ -31,14 +30,12 @@ fun formatSpan(until: Duration): String {
* Three answers rather than a nullable duration, because two of them shared `null` and they are not
* the same thing at all. A window the server sent no reset time for is one that is **not running**:
* the five-hour window is anchored to the block it started in, so between sessions there is nothing
* counting down and the API says so by omitting the field -- measured against a live response on
* 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work
* resumed. A timestamp that did arrive and could not be read is the genuinely unknown case, and it
* is the only one worth those words.
* counting down and the API says so by omitting the field. A timestamp that did arrive and could
* not be read is the genuinely unknown case.
*
* Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on
* the one row somebody reads before starting something big -- and the usage dialog, looking at the
* same field, quietly drew nothing. Two rules for one missing value; this is the rule.
* same field, quietly drew nothing.
*/
sealed class WindowEnd {
/** No reset time was sent, so nothing is running in this window. Not a failure to find out. */
@@ -10,24 +10,21 @@ private const val ANCHORS = "session-scroll"
*
* Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by
* the row key the list draws with. An index means nothing across a reopen, since the transcript is
* fetched newest-first and a session that has said anything since has renumbered every position.
* The row key looks stable and is not: a tool row is named after its run, `joinPages` gives a run
* the name of its newest half, and the newest half is whatever the newest page happened to start
* with -- so an active session renames its tool runs every time it is reopened, and an anchor
* naming one is never found. A seq is the server's own numbering, assigned once and never moved.
* fetched newest-first. The row key looks stable and is not: a tool row is named after its run,
* `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest
* page started with -- so an active session renames its tool runs every time it is reopened. A seq
* is the server's own numbering, assigned once and never moved.
*
* [unit] is which unit of the row the viewport started at -- see [TranscriptUnit.ordinal] -- and
* [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone
* is not a place: a reply is one seq and can be forty blocks long, and a reader stopped halfway
* down it is put back at that block, not at the reply.
* [unit] is which unit of the row the viewport started at and [offset] how far that unit was
* scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can
* be forty blocks long.
*/
data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0)
/**
* On this device rather than on the backend, which is where this app otherwise keeps state so every
* device sees it. Scroll position is the same exception a draft is: it is where the phone in
* somebody's hand is pointed, and having one device jump because another was scrolled would be a
* surprise rather than a convenience.
* somebody's hand is pointed.
*/
fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val stored =
@@ -36,8 +33,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val fields = stored.split(':')
val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null
val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null
// Positions saved before the unit was recorded name the row's oldest unit, which is the
// closest older place -- the same choice [unitIndexFor] makes when a unit is gone.
// Positions saved before the unit was recorded name the row's oldest unit, which is the closest
// older place -- the same choice [unitIndexFor] makes when a unit is gone.
return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0)
}
@@ -45,9 +42,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
* Records where [sessionId] is being read, or forgets it when [anchor] is null.
*
* The path out is reading to the newest end, which is what the caller passes null for: a session
* left at the bottom has nothing to restore and should open at the bottom, which is also the cheap
* case. A session *deleted* while it held an anchor leaves its key behind, for the reason and at
* the cost `Drafts.kt` describes.
* left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its
* key behind, for the reason and at the cost `Drafts.kt` describes.
*/
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
@@ -14,10 +14,9 @@ typealias ServerSettings = com.example.wgapplink.ServerSettings
/**
* This app's enrollment, which is the whole of what is product-specific about it.
*
* Both values are load-bearing and neither may be changed casually. The scheme is what routes a
* scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the
* token is already sealed under on every enrolled phone -- changing it would leave those phones
* reading as not enrolled, with no error to explain why.
* Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev
* Updater, and the key alias names the Android Keystore key the token is already sealed under on
* every enrolled phone -- changing it would leave those phones reading as not enrolled.
*/
private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key")
@@ -33,16 +33,14 @@ import androidx.lifecycle.repeatOnLifecycle
/**
* A session wanting attention, said over the app rather than through Android's drawer.
*
* Two places can carry the same fact and only one of them is right at a time. A row in the shade is
* for somebody looking at something else: it makes a sound, it waits however long it has to, and
* acting on it means leaving whatever they were doing. Somebody with this app open needs none of
* that -- they are already here, and what a tap on the notification would have done is what a tap
* on this does. So while these are on screen the stream is delivered here instead, which is
* arranged by the collection below and nothing else; see `NotificationService.forTheScreen`.
* Two places can carry the same fact and only one is right at a time. A row in the shade is for
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
* while these are on screen the stream is delivered here instead, which is arranged by the
* collection below and nothing else.
*
* A banner can go three ways, and each is somebody deciding something different: tapped, which
* opens the session; pushed off either side; or left alone, in which case it goes by itself when
* the bar across its foot runs out.
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
*/
@Composable
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
@@ -58,28 +56,26 @@ fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Mod
arrivals++
val alert = SessionAlert(notification, arrivals)
// One banner per session, replacing that session's own -- the same rule the
// drawer follows, and for the same reason: a session that finished and then
// asked a question is one thing to know about, the question. It keeps its
// place in the queue rather than moving to the end, because the reader may
// already be reaching for it.
// drawer follows: a session that finished and then asked a question is one
// thing to know about, the question. It keeps its place in the queue rather
// than moving to the end, because the reader may already be reaching for it.
val already = queue.indexOfFirst {
it.notification.sessionId == notification.sessionId
}
if (already >= 0) queue[already] = alert else queue.add(alert)
}
} finally {
// Leaving the app hands the job back to the drawer, so nothing arriving while it
// is away is lost. What would be lost is the truth of what is already up: these
// say a session wants somebody *now*, and one still sitting here on a return
// several minutes later is a claim nobody checked. Frozen, too -- Compose stops
// the clock with the window, so the timer that was going to retire it has been
// standing still the whole time.
// Leaving the app hands the job back to the drawer, so nothing arriving while it is
// away is lost. What would be lost is the truth of what is already up: these say a
// session wants somebody *now*, and one still sitting here on a return several
// minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock
// with the window.
queue.clear()
}
}
}
// Oldest at the top, so a new one appears below the ones already being read instead of
// shoving them down the screen mid-reach.
// Oldest at the top, so a new one appears below the ones already being read instead of shoving
// them down the screen mid-reach.
Column(modifier.fillMaxWidth().padding(8.dp)) {
queue.forEach { alert ->
key(alert.arrival) {
@@ -103,9 +99,7 @@ private data class SessionAlert(val notification: SessionNotification, val arriv
* One banner: what wants attention, and how long this has left to say so.
*
* The bar and the going away are one value rather than a bar beside a timer, because two of them
* would be two accounts of the same countdown and only one can be the one that fires. What is drawn
* is therefore the thing that decides, which is the only arrangement where a bar that has emptied
* cannot be sitting under a banner that is still there.
* would be two accounts of the same countdown and only one can be the one that fires.
*/
@Composable
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
@@ -135,9 +129,7 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
),
// Outlined, because the step it needs to make is not one this palette can make with a
// tint: the card under a banner on the session list is the same surface, so a banner
// relying on colour alone reads as one more row that happens to be in the way. The
// border is the one cue, and the elevation beside it is the platform's shadow rather
// than a second tint -- Material draws no tonal overlay over a container stated here.
// relying on colour alone reads as one more row in the way. The border is the one cue.
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
) {
@@ -145,8 +137,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
Text(
alert.notification.title,
style = MaterialTheme.typography.titleSmall,
// One line, cut at the tail: a session is identified by the start of its
// name, and a banner that grew with the name would move the one below it.
// One line, cut at the tail: a session is identified by the start of its name,
// and a banner that grew with the name would move the one below it.
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -163,9 +155,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
LinearProgressIndicator(
progress = { life.value },
// Blue because it is reporting how much of something is left rather than passing
// judgement on it -- the reason `progressColor` exists. Stated beside the track,
// which is the card's own colour so that the spent part reads as empty rather
// than as a second bar.
// judgement on it. Stated beside the track, which is the card's own colour so that
// the spent part reads as empty rather than as a second bar.
color = progressColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
drawStopIndicator = {},
@@ -180,7 +171,6 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
* How long a banner stays if nobody touches it.
*
* Long enough to read a session name and a line, short enough that a stack of them clears itself
* while somebody is still on the screen that produced them. The bar makes the number visible, so
* this is a duration the reader can watch rather than one they have to learn.
* while somebody is still on the screen that produced them. The bar makes the number visible.
*/
private const val ALERT_LIFE_MS = 6_000
@@ -49,10 +49,9 @@ data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean)
/**
* Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling
* does not refetch.
*
* Shared by the transcript's images and the composer's pending attachments, because the fetch, the
* decode and the two-state answer are one block of logic that had been written twice.
* does not refetch. Shared by the transcript's images and the composer's pending attachments,
* because the fetch, the decode and the two-state answer are one block of logic that had been
* written twice.
*/
@Composable
fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap {
@@ -75,15 +74,12 @@ fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: Stri
* An image in the transcript: a fixed-height thumbnail that opens full screen.
*
* The height is decided before the bytes arrive and never changes. An image row that grew when it
* finished loading pushed everything below it, so a transcript being read scrolled itself while
* somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport
* moved the text under the reader's eyes. Reserving the final height makes loading invisible, which
* is what it should be.
* finished loading pushed everything below it, so a transcript being read scrolled itself -- and in
* a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
*
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
* than as a page of its own. Full size is one tap away -- but the full-size view itself is not
* here. [onOpen] hands the ref to the screen, which draws [SessionImageViewer] outside the list;
* see that function for the reason.
* than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
* screen, which draws [SessionImageViewer] outside the list.
*/
@Composable
fun SessionImage(
@@ -97,9 +93,8 @@ fun SessionImage(
val heightPx = with(LocalDensity.current) { height.roundToPx() }
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
when (val image = bitmap) {
// Two states, not one: an image still arriving and an image that will never arrive
// look nothing alike to a reader who can do something about the second. So one gets a
// spinner in the space the picture is about to fill, and the other gets words.
// Two states, not one: an image still arriving and an image that will never arrive look
// nothing alike to a reader who can do something about the second.
null ->
if (failed) {
Text(
@@ -130,15 +125,12 @@ fun SessionImage(
* `Read` on its own is a row of one call, and the moment the next call arrives the two become a
* group -- a different composable in a different part of the tree, so everything the old subtree
* remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the
* transcript because the session made another tool call. The same happens to a row regrouped by a
* page of history landing.
* transcript because the session made another tool call.
*
* Held by the screen, none of that reaches it: what is open is a property of the screen, not of
* whichever row happened to draw the thumbnail.
* Held by the screen, none of that reaches it: what is open is a property of the screen.
*
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
* through. Paid deliberately rather than plumbed around: it is one request for a picture somebody
* asked to see, and the loading and unavailable states below are the same two the thumbnail draws.
* through. Paid deliberately: it is one request for a picture somebody asked to see.
*/
@Composable
fun SessionImageViewer(
@@ -157,9 +149,9 @@ fun SessionImageViewer(
contentAlignment = Alignment.Center,
) {
when (val image = bitmap) {
// Two states, not one, exactly as the thumbnail has them: still coming, and never
// coming. Stated in white because this box paints its own black behind them and a
// theme colour would be picked against a surface that is not there.
// Two states, not one, exactly as the thumbnail has them. Stated in white because
// this box paints its own black behind them and a theme colour would be picked
// against a surface that is not there.
null ->
if (failed) {
Text(
@@ -170,8 +162,7 @@ fun SessionImageViewer(
} else {
// The whole dialog is the area this picture is about to fill, so the
// spinner sits in the middle of it. White for the same reason the words
// beside it are: this box paints its own black, and a theme colour would
// be chosen against a surface that is not there.
// beside it are.
CircularProgressIndicator(color = Color.White)
}
else -> ZoomableImage(image)
@@ -185,11 +176,10 @@ fun SessionImageViewer(
*
* A square of the row's own height rather than the full width of the transcript: the height is what
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
* placeholder would promise a picture wider than most of them turn out to be. Square is the closest
* thing to "the size of it" that can be drawn before knowing.
* placeholder would promise a picture wider than most turn out to be.
*
* Tinted, so the reader can see that something is being kept for a picture. That is also what
* distinguishes it from the failure beside it, which is words on the ordinary surface.
* Tinted, so the reader can see that something is being kept for a picture -- which is also what
* distinguishes it from the failure beside it, words on the ordinary surface.
*/
@Composable
private fun LoadingImage(height: Dp) {
@@ -210,8 +200,8 @@ private val LOADING_SPINNER = 24.dp
* Four lines of the body style the transcript is set in.
*
* Measured from the type rather than written as a dp, so it stays four lines when the text size
* changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded
* height would be wrong.
* changes -- including when the reader has scaled fonts up, which is when a hardcoded height is
* wrong.
*/
@Composable
private fun thumbnailHeight(): Dp {
@@ -226,8 +216,7 @@ private fun thumbnailHeight(): Dp {
* Nearest neighbour when the image is being enlarged, smooth when it is being shrunk.
*
* A small image blown up with interpolation turns into a blur that hides what it is -- the same
* image with hard pixel edges stays readable. Shrinking wants the opposite, so this is a decision
* per image rather than a preference set once.
* image with hard pixel edges stays readable. Shrinking wants the opposite.
*/
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High
@@ -237,7 +226,7 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality
*
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
* visible, which is the thing a reader wants first; zoom is theirs from there.
* visible.
*/
@Composable
private fun ZoomableImage(image: ImageBitmap) {
@@ -1,7 +1,9 @@
package com.example.aiapp
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -9,6 +11,7 @@ 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
@@ -17,6 +20,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -29,6 +33,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -46,28 +53,56 @@ fun SessionListScreen(
settings: ServerSettings,
reloadToken: Int,
onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit,
) {
val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Failures that belong to one session rather than to the list, keyed by
// its id and shown on its own card. The two scopes are decided by
// whether the server answered: it answered and refused, so this says
// nothing about the other rows, where a server that has stopped
// answering leaves every row stale and is `listState`'s to report.
// Which session cards are expanded to show their subagents, and what each expansion fetched.
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
// from
// whatever the server last said, and this belongs to the reader's own choice, which survives a
// refresh.
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
var subagentLoads by remember {
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
}
fun loadSubagents(sessionId: String) {
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
scope.launch {
subagentLoads =
subagentLoads +
(sessionId to
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
)
} catch (e: ApiException) {
LoadState.failed(e)
})
}
}
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused,
// so this says nothing about the other rows.
//
// Cleared on the next successful load below -- an entry outlives its
// session otherwise, and would reappear against whatever the phone
// fetched next.
// Cleared on the next successful load below -- an entry outlives its session otherwise.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which sessions have a delete in flight. A set of ids rather than a flag on the row,
// because the rows are rebuilt from whatever the server last said and this belongs to the
// request rather than to the session.
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
// the rows are rebuilt from whatever the server last said and this belongs to the request.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
// This phone's copies of these sessions' transcripts, pruned from here because this is where a
// session stops existing. See TranscriptCache.
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
fun refresh() {
listState = LoadState.Loading
scope.launch {
@@ -76,6 +111,21 @@ fun SessionListScreen(
val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap()
// The path out for a cached transcript whose session was deleted somewhere
// else. This list is the only place that ever learns the full set. On the
// answer rather than in `finally`: a list that failed to arrive says nothing
// about which sessions exist.
withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
}
// A session gone from this answer cannot still be expanded, and an expanded one
// that is still here asks again -- its subagents may have changed since the
// last
// fetch.
val ids = loaded.value.map { it.id }.toSet()
expandedSessions = expandedSessions intersect ids
subagentLoads = subagentLoads.filterKeys { it in ids }
expandedSessions.forEach(::loadSubagents)
loaded
} catch (e: ApiException) {
LoadState.failed(e)
@@ -89,12 +139,9 @@ fun SessionListScreen(
Column(Modifier.fillMaxSize().padding(16.dp)) {
when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is
// already a whole sentence naming the address and what to
// check, so a prefix here read "Couldn't reach the server:
// Couldn't reach the server at ...". It was also a guess --
// a delete that the server itself refused had reached it
// fine.
// The message as Api.kt wrote it, with nothing added: it is already a whole
// sentence naming the address and what to check, so a prefix here read "Couldn't
// reach the server: Couldn't reach the server at ...".
is LoadState.Error ->
Text(
state.message,
@@ -108,8 +155,7 @@ fun SessionListScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Awaiting-answer first (the point of the screen), then
// most recently active.
// Awaiting-answer first (the point of the screen), then most recently active.
val ordered =
state.value.sortedWith(
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
@@ -123,6 +169,17 @@ fun SessionListScreen(
deleting = session.id in deleting,
onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session },
expanded = session.id in expandedSessions,
subagents = subagentLoads[session.id],
onToggleSubagents = {
if (session.id in expandedSessions) {
expandedSessions = expandedSessions - session.id
} else {
expandedSessions = expandedSessions + session.id
loadSubagents(session.id)
}
},
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
)
Spacer(Modifier.height(12.dp))
}
@@ -141,40 +198,37 @@ fun SessionListScreen(
confirmingDelete?.let { session ->
// Reset per session, so a toggle turned on for one conversation is not still on for the
// next one somebody opens this dialog for. Off to begin with: see [deleteSession].
// next. Off to begin with: see [deleteSession].
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete \"${session.title}\"?") },
text = {
// Two different acts behind one button, so it says which one this is. What
// separates them is whether the *driver* keeps its own record of the
// conversation -- the Claude Code CLI does, under ~/.claude/projects, whether
// this app spawned the session or imported it; echo and llama.cpp do not, and
// for those the app's transcript is the only copy there is.
// separates them is whether the *driver* keeps its own record of the conversation
// -- the Claude Code CLI does, whether this app spawned the session or imported it;
// echo and llama.cpp do not.
//
// This used to branch on `imported`, above a comment asserting that "a session
// started here has no copy anywhere". That was simply false for every
// claude-cli session this app spawned, and the two warnings disagreed about
// sessions that were equally recoverable. Getting it wrong in that direction
// is the expensive one: "this can't be undone", said of something that can,
// spends the credibility the sentence needs on the sessions where it is true.
// started here has no copy anywhere". That was false for every claude-cli session
// this app spawned, and getting it wrong in that direction is the expensive one:
// "this can't be undone", said of something that can, spends the credibility the
// sentence needs.
//
// Neither branch promises a restore. The recoverable one says what is known --
// the driver keeps its own record -- rather than that the file is still there,
// which nothing here checked; and it names what goes either way, because this
// app's transcript holds images, peer messages and commands that the CLI's own
// record never had.
// Neither branch promises a restore. The recoverable one says what is known -- the
// driver keeps its own record -- rather than that the file is still there, and it
// names what goes either way, because this app's transcript holds images, peer
// messages and commands the CLI's own record never had.
Column {
Text(
when {
!session.keepsOwnTranscript ->
"Kills the process and deletes the conversation. Nothing else " +
"keeps a copy, so this can't be undone."
// The sentence below is the one the toggle makes false, which is why
// it is written twice rather than appended to: leaving "should still
// be there to import again" on screen beside a switch that removes it
// is the reassurance being read at the moment it stops being true.
// The sentence below is the one the toggle makes false, which is why it
// is written twice rather than appended to: leaving "should still be
// there to import again" on screen beside a switch that removes it is
// the reassurance being read at the moment it stops being true.
alsoDeleteForeign ->
"Kills the process and deletes both copies of the conversation: " +
"this app's, and Claude Code's own transcript on the " +
@@ -189,12 +243,12 @@ fun SessionListScreen(
)
// Only where there is a second copy to decide about. Absent rather than
// disabled, because this is not a capability being withheld: for echo and
// llama.cpp there is no other transcript, and a switch offering to delete
// one would be asking about something that does not exist.
// llama.cpp there is no other transcript, and a switch offering to delete one
// would be asking about something that does not exist.
if (session.keepsOwnTranscript) {
Spacer(Modifier.height(16.dp))
// Its own row rather than beside the paragraph: a switch is taller than
// a line of text and re-centres whatever shares a row with it.
// Its own row rather than beside the paragraph: a switch is taller than a
// line of text and re-centres whatever shares a row with it.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Delete Claude Code's transcript too",
@@ -215,19 +269,20 @@ fun SessionListScreen(
onClick = {
confirmingDelete = null
// Marked here rather than after the request returns: the row has to say
// something is happening to it from the moment it is asked for, which
// is the whole of what this state is for.
// something is happening to it from the moment it is asked for.
deleting = deleting + session.id
deleteErrors = deleteErrors - session.id
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it.
transcriptCache.session(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
// that was never in doubt.
// Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the
// reader an empty screen, to report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState =
@@ -246,8 +301,8 @@ fun SessionListScreen(
}
}
) {
// Coloured by consequence: this takes something away, and does so wherever
// it appears -- the same rule the import screen's Delete follows.
// Coloured by consequence: this takes something away, and does so wherever it
// appears -- the same rule the import screen's Delete follows.
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
@@ -269,18 +324,23 @@ private fun SessionCard(
*
* Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its
* way out without claiming it has gone: a row removed the moment Delete is pressed is a promise
* about a request that has not been answered yet, and putting it back when the server refuses
* is worse than never having taken it away.
* about a request that has not been answered yet.
*/
deleting: Boolean,
onOpen: () -> Unit,
onLongPress: () -> Unit,
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
expanded: Boolean,
/** What the expander's own fetch answered, or null before it has been asked. */
subagents: LoadState<List<SubagentSummary>>?,
onToggleSubagents: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
) {
BusyItem(label = if (deleting) "deleting" else null) {
Card(
// Off while the delete is in flight: a card that still opens a session it is
// deleting is a race the reader can start by tapping. On the card rather than in
// [BusyItem], which leaves gestures alone so the list still scrolls.
// Off while the delete is in flight: a card that still opens a session it is deleting
// is a race the reader can start by tapping. On the card rather than in [BusyItem],
// which leaves gestures alone so the list still scrolls.
Modifier.fillMaxWidth()
.combinedClickable(
enabled = !deleting,
@@ -303,9 +363,9 @@ private fun SessionCard(
Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
// Machine, then what runs on it, then what it is set to: the same order
// and separator as the session screen's header and the usage dialog, so
// one pair of facts is not written three ways.
// Machine, then what runs on it, then what it is set to: the same order and
// separator as the session screen's header and the usage dialog, so one
// pair of facts is not written three ways.
listOfNotNull(
session.setupName,
session.provider,
@@ -324,19 +384,112 @@ private fun SessionCard(
}
error?.let {
Spacer(Modifier.height(8.dp))
// The server's own words, unprefixed, the way every other
// failure in this app is shown.
// The server's own words, unprefixed, the way every other failure is shown.
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
// Nothing at all for a card with no subagents: a disabled expander here would be
// noise on every ordinary session's card. Its own row at the bottom rather than
// beside the title or the machine line, so opening it never displaces text that was
// already on screen -- see UI_RULES on a control not displacing the text beside it.
if (session.subagents > 0) {
Spacer(Modifier.height(8.dp))
// The platform's minimum touch height, not the chevron's own ten or so dp:
// at the chevron's height a tap meant for it landed on the first subcard
// beneath and opened a subagent instead.
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(enabled = !deleting, onClick = onToggleSubagents)
.semantics {
contentDescription =
if (expanded) "Collapse subagents" else "Expand subagents"
},
) {
Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
if (expanded) {
Spacer(Modifier.height(4.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (subagents) {
null,
is LoadState.Loading ->
CircularProgressIndicator(
modifier = Modifier.width(20.dp).height(20.dp),
strokeWidth = 2.dp,
)
is LoadState.Error ->
// Said here rather than left silent: a fetch that failed and an
// expander that simply found nothing must not look the same --
// see UI_RULES on designing the unknown state first.
Text(
subagents.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded ->
subagents.value.forEach { subagent ->
SubagentCard(
subagent,
onClick = { onOpenSubagent(subagent) },
)
}
}
}
}
}
}
}
}
}
/**
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
* filled one, so the nesting reads as one step rather than as another session.
*/
@Composable
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped.
*/
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@Composable
fun StatusText(status: String) {
val (label, color) =
@@ -345,16 +498,16 @@ fun StatusText(status: String) {
"running" -> "running" to runningColor
"compacting" -> "compacting" to commandColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree:
// the session is not idle and has not exited, nobody has been able to find out
// which. A muted colour alone would read as one of the quiet states.
// Said in words, because it differs in kind from the others rather than in degree: the
// session is not idle and has not exited, nobody has been able to find out which. A
// muted colour alone would read as one of the quiet states.
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (sessionWorking(status)) {
// The same colour as the word beside it: the two are one signal, and a spinner in
// the theme's accent says the state is something other than what the label says.
// The same colour as the word beside it: the two are one signal, and a spinner in the
// theme's accent says the state is something other than what the label says.
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
File diff suppressed because it is too large. Load diff
@@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -26,6 +28,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -39,12 +45,11 @@ import kotlinx.coroutines.withContext
* controls and hid the thing they act on.
*
* The model and the permission mode are deliberately still on the session's own bar, because those
* are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs
* with the thing it acts on.
* are changed *while* reading a turn -- "not this model, try that one".
*
* Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it,
* and a paragraph under every one of them made the dialog longer than the conversation it covers.
* Failures still get their words: those are what the reader cannot work out by looking.
* Captions are for what a control costs rather than for what it is. A paragraph under every control
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
* and Reload do, because what those two take away is not visible from here.
*/
@Composable
fun SessionSettingsDialog(
@@ -55,24 +60,62 @@ fun SessionSettingsDialog(
*/
title: String,
onRenamed: (String) -> Unit,
/**
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
*
* Taken from the row this dialog was opened over rather than fetched, because unlike the
* notification switch there is nothing else that changes it: the level is this app's to set and
* the server does not resolve it into something else.
*/
effort: String?,
/** Whether a level does anything here; the row is left out entirely where it does not. */
takesEffort: Boolean,
/**
* What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it.
*/
cachedBytes: Long?,
onReload: () -> Unit,
onDismiss: () -> Unit,
/**
* Copies what this session costs to draw. Built by the session screen, because everything it
* measures is that screen's own state.
*/
onCopyRenderReport: () -> Unit,
/**
* Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on
* every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than
* always-present-but-disabled: this has no meaning at all outside the bench build, and a
* control with nothing behind it on every other build is not a state worth drawing.
*/
onRunBenchmark: (() -> Unit)? = null,
) {
val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) }
var level by remember(sessionId) { mutableStateOf(effort) }
var effortError by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
// whenever the list was last fetched, so drawing the switch straight from it would show a
// position that may have been changed since -- from here or from another device -- with
// nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside
// it, which is what not knowing looks like: distinguishable from off, and from a refusal.
// position that may have been changed since. Until the answer arrives the switch is disabled
// and a spinner sits beside it, which is what not knowing looks like.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the
// switch above is: the row this dialog opened over is a snapshot, and a path drawn from it
// could be one somebody changed from another device. An empty answer is a session that was
// never given a directory, which is not the same as one whose directory is unknown -- the
// field is only enabled once one of those two is settled.
// The same three-state shape the notification switch has, for the same reason: until the
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
// When the server next intends to ask whether the limit has lifted, or null when nothing is
// waiting. Read once with everything else: it moves on the server's schedule, not this
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
// told.
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
var resumeError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the switch
// above is. An empty answer is a session that was never given a directory, which is not the
// same as one whose directory is unknown -- the field is only enabled once one of those is
// settled.
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
var typedCwd by remember(sessionId) { mutableStateOf("") }
var cwdError by remember { mutableStateOf<String?>(null) }
@@ -82,13 +125,18 @@ fun SessionSettingsDialog(
try {
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
notify = fresh.notify
autoResume = fresh.autoResume
resumeMessage = fresh.autoResumeMessage
resumeAt = fresh.resumeAt
cwd = fresh.cwd.orEmpty()
typedCwd = fresh.cwd.orEmpty()
} catch (e: ApiException) {
// Left unknown rather than falling back to the stale row: the switch stays
// disabled, instead of offering a position nothing confirmed.
// Left unknown rather than falling back to the stale row: the switch stays disabled,
// instead of offering a position nothing confirmed.
notifyError = e.message
notify = null
resumeError = e.message
autoResume = null
}
}
@@ -117,9 +165,29 @@ fun SessionSettingsDialog(
}
}
/**
* Chooses a thinking level, which ends the process the old level was launched with.
*
* Put back if the request is refused, for the reason the notification switch below gives: a
* control that stays where it was put after a refusal is stating something untrue.
*/
fun setEffort(chosen: String?) {
val was = level
level = chosen
effortError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
} catch (e: ApiException) {
level = was
effortError = e.message
}
}
}
// Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow
// tunnel, and one that stays moved after a refusal lies.
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
// and one that stays moved after a refusal lies.
fun setNotify(wanted: Boolean) {
val was = notify
notify = wanted
@@ -134,6 +202,39 @@ fun SessionSettingsDialog(
}
}
/**
* Turns auto-resume on or off, or changes what it would say.
*
* One request for both, because the server takes one: switching it on and typing the message
* are two halves of the same decision, and sending them separately would leave a moment where
* the session is armed with the old words.
*
* Put back if refused, like the notification switch. Turning it off also clears what was
* scheduled -- said here rather than only on the server, or the row would go on naming a time
* that no longer exists.
*/
fun setAutoResume(on: Boolean, message: String) {
val wasOn = autoResume
val wasMessage = resumeMessage
val wasAt = resumeAt
autoResume = on
resumeMessage = message
if (!on) resumeAt = null
resumeError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
setSessionAutoResume(settings, sessionId, on, message)
}
} catch (e: ApiException) {
autoResume = wasOn
resumeMessage = wasMessage
resumeAt = wasAt
resumeError = e.message
}
}
}
// Nothing to do when the name has not changed, so the button says so rather than sending a
// request whose success would look exactly like the failure of having typed nothing.
val changed = name.trim().isNotEmpty() && name.trim() != title
@@ -149,7 +250,7 @@ fun SessionSettingsDialog(
onRenamed(chosen)
} catch (e: ApiException) {
// Reported here, where it happened, because this dialog is the only place that
// knows a rename was attempted -- the session behind it shows nothing about it.
// knows a rename was attempted.
error = e.message
saving = false
}
@@ -160,7 +261,10 @@ fun SessionSettingsDialog(
onDismissRequest = onDismiss,
title = { Text("Session settings") },
text = {
Column {
// Scrollable, because this dialog grew past a screenful: a Material dialog constrains
// its own height and clips what does not fit, so the last control on the list is one
// large system font away from being unreachable with nothing on screen to say so.
Column(Modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
@@ -168,8 +272,8 @@ fun SessionSettingsDialog(
singleLine = true,
enabled = !saving,
modifier = Modifier.fillMaxWidth(),
// The keyboard's own action does what the button does: a one-field form
// where the return key does nothing is a form people press return at anyway.
// The keyboard's own action does what the button does: a one-field form where
// the return key does nothing is a form people press return at anyway.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { save() }),
)
@@ -194,8 +298,8 @@ fun SessionSettingsDialog(
enabled = notify != null,
)
}
// Beside the switch that failed, not with the rename's error: they are two
// requests and a reader has to be able to tell which one the server refused.
// Beside the switch that failed, not with the rename's error: they are two requests
// and a reader has to be able to tell which one the server refused.
notifyError?.let {
Text(
it,
@@ -204,6 +308,70 @@ fun SessionSettingsDialog(
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
if (autoResume == null && resumeError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = autoResume == true,
onCheckedChange = { setAutoResume(it, resumeMessage) },
enabled = autoResume != null,
)
}
// Disabled rather than hidden while the switch is off: a field that comes and goes
// makes its own presence the signal, and a visible one teaches what the switch will
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
// sentence is one request instead of one per letter.
OutlinedTextField(
value = resumeMessage,
onValueChange = { resumeMessage = it },
label = { Text("Message to send") },
// What an empty field means, in the field: the server's own word rather than a
// session poked with nothing to read.
placeholder = { Text(DEFAULT_RESUME_MESSAGE) },
singleLine = true,
enabled = autoResume == true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
)
// What it does and what it costs, in the order it happens. The last sentence is the
// one that matters: the time below is when the server will *ask*, not a promise
// about when the session speaks.
Text(
"When this session stops because the account is out of quota, the server " +
"checks the limit and sends this message once it has lifted. It checks " +
"again if the limit is still on.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Only where something is actually waiting. Absent is not a state worth a row: a
// session that has not hit a limit has nothing scheduled, which the reader can see
// from the switch.
resumeAt?.let { at ->
Text(
"Waiting now -- next check ${formatCheckTime(at)}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
resumeError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
@@ -212,9 +380,9 @@ fun SessionSettingsDialog(
value = typedCwd,
onValueChange = { typedCwd = it },
label = { Text("Working directory") },
// What the field cannot say by being empty: a session that was never
// given one starts wherever its launcher does, and this names that
// rather than showing a path nobody chose.
// What the field cannot say by being empty: a session that was never given
// one starts wherever its launcher does, and this names that rather than
// showing a path nobody chose.
placeholder = { Text("wherever the session was started") },
singleLine = true,
enabled = cwd != null && !movingCwd,
@@ -234,9 +402,8 @@ fun SessionSettingsDialog(
}
}
// The whole of what pressing Move does, where it is about to be pressed. A
// directory is settled when the process is spawned, so there is no changing one
// under a running session -- it is ended, and the next thing said to the session
// starts it in the new place.
// directory is settled when the process is spawned, so it is ended and the next
// thing said to the session starts it in the new place.
Text(
"Moving stops the session's process. It starts again in the new directory " +
"with the next message, or with Start.",
@@ -250,6 +417,82 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall,
)
}
// Left out rather than disabled, the one place this dialog does that: a disabled
// control teaches what the thing can do, and a llama session cannot do this at all
// -- the row would be teaching something false about it.
if (takesEffort) {
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Thinking", modifier = Modifier.weight(1f))
PickerButton(
current = level ?: DEFAULT_EFFORT,
// The level the CLI picks for itself is in the list as well as in the
// button, so leaving a level is not a one-way trip -- the same
// correction the model picker carries.
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
onPick = { chosen ->
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
},
)
}
// What it costs, said where it is about to be pressed, like Move above: the
// CLI reads the level when it launches and has no control request for
// changing one.
Text(
"Changing this stops the session's process. It starts again with the " +
"next message, or with Start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
effortError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Transcript", modifier = Modifier.weight(1f))
// The size is what the button discards, and the unknown state is drawn rather
// than guessed: a spinner while the directory is being measured, and words when
// there is nothing there, because "nothing cached" and "0 B" read as different
// claims.
when {
cachedBytes == null ->
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
else ->
Text(
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(12.dp))
// Enabled whether or not anything is cached: "what I see disagrees with the
// machine" is a state an empty cache can be in too, and a control that comes
// and goes makes its own presence the signal.
TextButton(onClick = onReload) { Text("Reload") }
}
// Captioned, unlike the controls above it, for the same reason Move is: what it
// costs is not visible, and neither is the case it exists for.
Text(
"Reload throws away this phone's copy and fetches the transcript from the " +
"server again. Use it when what is shown here disagrees with the file " +
"on the machine.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
@@ -258,10 +501,39 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
// About this session, which is what everything in here is -- and it was on the
// header until 2026-09-03, where the folder button now is. It copies rather than
// opening anything, so it says so and then says it happened: a row that looks like
// a control and gives no sign of having run is one people press twice.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Render timings", modifier = Modifier.weight(1f))
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
}
// Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because
// ui-trace and the emulator smoke run find it by that label, the same way every
// other control here is found -- see AGENTS.md's "Driving the UI".
onRunBenchmark?.let { run ->
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("P0 benchmark", modifier = Modifier.weight(1f))
TextButton(onClick = run) { Text("Run benchmark") }
}
}
}
},
// Disabled rather than absent while there is nothing to save: a button that comes and
// goes makes its own presence the signal, and its absence cannot say why.
// Disabled rather than absent while there is nothing to save: a button that comes and goes
// makes its own presence the signal, and its absence cannot say why.
confirmButton = {
TextButton(onClick = { save() }, enabled = changed && !saving) {
Text(if (saving) "Saving..." else "Save")
@@ -270,3 +542,21 @@ fun SessionSettingsDialog(
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
/**
* When the server will next look, as a local time.
*
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
* is looking at it.
*/
private fun formatCheckTime(epochSeconds: Double): String =
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
} catch (_: Exception) {
// A time that cannot be read is not a time to show: the sentence above still says a check
// is coming, which is the part the reader can act on.
"soon"
}
@@ -34,20 +34,19 @@ sealed class SessionUsage {
/**
* This machine meters nothing, so there is no window to show.
*
* Separate from [Unavailable], and the distinction is the whole point: a session on `echo` or
* on a local llama.cpp has no paid quota at all, which is a fact about how it was set up and
* not a failure to find something out. The backend never asks such a machine, so it returns no
* snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of
* answering with the nearest available word. Drawn as nothing, because there is nothing.
* Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
* local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
* failure to find something out. The backend never asks such a machine, and reading that
* silence as "couldn't find out" is answering with the nearest available word.
*/
data object NotMetered : SessionUsage()
/**
* The question could not be answered, and why.
*
* Its own state because "we couldn't find out" and "none of it is used" are the pair that must
* never share an appearance: a bar sitting at zero because a machine is unreachable reads as
* plenty of headroom, which is the opposite of the truth.
* Its own state because "we couldn't find out" and "none of it is used" must never share an
* appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
* headroom.
*/
data class Unavailable(val why: String) : SessionUsage()
}
@@ -59,28 +58,34 @@ private const val REFRESH_MS = 60_000L
* One poll of every machine's limits, and the handle to ask again.
*
* A screen shows this answer in more than one place -- the bar under the session header, the colour
* of the button beside it, and the dialog that button opens -- and each of those used to fetch for
* itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole
* refresh interval old when the dialog opens with a fresh one, so the header read 42% while the
* screen over it read 47%, about a number somebody is deciding on. One feed per screen, and
* [refresh] moves both.
* of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
* Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
* old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
* 47%.
*/
class UsageFeed(
val snapshots: LoadState<List<UsageSnapshot>>,
/**
* A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed].
*/
/** A fetch is outstanding. Only ever true over an answer already shown. */
val refreshing: Boolean,
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit,
) {
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
fun forSetup(setup: String): SessionUsage =
when (val state = snapshots) {
/**
* What meters [session], and what that meter came back as. See [usageFor] for the states.
*
* A session rather than a machine, because a machine is not what is metered: one machine runs
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
*/
fun forSession(session: SessionSummary): SessionUsage {
// Settled without asking anybody: a session nothing meters has nothing to check, and
// "checking" is what the fetch's own states would say about it for as long as one is out.
val provider = session.usageProvider ?: return SessionUsage.NotMetered
return when (val state = snapshots) {
is LoadState.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, setup)
is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
}
}
}
/**
@@ -93,15 +98,15 @@ class UsageFeed(
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
var refreshing by remember { mutableStateOf(true) }
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh
// also resets the countdown to the next one rather than leaving one due immediately after.
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
// resets the countdown rather than leaving one due immediately after.
var asked by remember { mutableIntStateOf(0) }
LaunchedEffect(asked) {
while (true) {
refreshing = true
// Replaces the answer only once the next one is in hand: dropping back to Loading
// would blank a bar somebody is reading for the length of a round trip, and what was
// on screen is still the last thing the machine actually said.
// Replaces the answer only once the next one is in hand: dropping back to Loading would
// blank a bar somebody is reading for the length of a round trip, and what was on
// screen is still the last thing the machine actually said.
snapshots =
try {
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
@@ -121,13 +126,11 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows came back rather than the three Claude sends today -- the backend
* deliberately passes windows it does not recognise straight through, so a fourth one is a thing
* that happens rather than a thing to notice later.
* passes windows it does not recognise straight through.
*
* Every state that is not a measurement takes the ordinary control colour instead. That is the
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind
* the button is where those say, in words, which one they are.
* unknown blue would say "measured, and fine" about a machine nobody could reach.
*/
@Composable
fun usageGlyphColour(usage: SessionUsage): Color =
@@ -145,18 +148,17 @@ fun usageGlyphColour(usage: SessionUsage): Color =
* going, and it was a screen away from the place that decision gets made. It reports on this
* session's machine alone -- the dialog is still where every machine is compared.
*
* What it shows is the paid service's own metering, fetched from the machine that holds the
* account. It is never derived from what this app has watched go past: the transcript's token
* counts are a different quantity, measured differently, and a bar shaped like a quota gauge built
* out of them would be a guess wearing a measurement's clothes.
* What it shows is the paid service's own metering, never derived from what this app has watched go
* past: the transcript's token counts are a different quantity, measured differently, and a bar
* built out of them would be a guess wearing a measurement's clothes.
*/
@Composable
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
DebugStats.count("usage bar recomposed")
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
// happened to move would sit at a stale figure for hours.
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
// would sit at a stale figure for hours.
var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
@@ -165,9 +167,15 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
}
}
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would
// report a problem about a setup somebody chose, on every screen, forever.
if (usage is SessionUsage.NotMetered) {
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// a problem about a setup somebody chose, on every screen, forever.
//
// And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
// second and was then taken away. A row that has to be withdrawn is worse than one that
// arrives late.
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
return
}
@@ -178,9 +186,10 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
// Words, not a colour and not an empty bar: every one of these is a different kind of
// answer from "this much is used", and only words carry a difference in kind.
when (val state = usage) {
SessionUsage.NotMetered -> Unit
// Both handled above, before the row exists at all.
SessionUsage.NotMetered,
SessionUsage.Waiting -> Unit
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
is SessionUsage.Known -> {
val window = state.windows.firstOrNull { it.kind == "session" }
if (window == null) {
@@ -222,9 +231,8 @@ private fun UsageNote(text: String) {
* The percentage on its own does not answer the question it gets asked, which is whether to start
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
*
* The window's end has two missing cases and they are worded differently on purpose; see
* [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is
* no countdown to report and inventing one would be the same fault as inventing the number.
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
* that is not running gets the percentage and nothing else.
*/
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%"
@@ -242,17 +250,22 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
}
/**
* One machine's snapshot, out of every machine's.
* One meter's snapshot, out of every machine's: [setup]'s row for [provider].
*
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
* service on one machine.
*
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered.
*/
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
// No snapshot at all means the backend never asked, which it only does for a machine with
// nothing metered on it. That is a different answer from having asked and failed.
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
// No snapshot at all means the backend never asked, which it only does where there is nothing
// to ask about. That is a different answer from having asked and failed.
val mine =
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
?: return SessionUsage.NotMetered
if (mine.state != "ok") {
return SessionUsage.Unavailable(mine.detail ?: mine.state)
}
@@ -47,15 +47,14 @@ fun SettingsScreen(
val context = LocalContext.current
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
// Never pre-filled from the stored token: this screen shouldn't be a
// way to read the credential back off the device.
// Never pre-filled from the stored token: this screen shouldn't be a way to read the credential
// back off the device.
var token by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val scanLauncher =
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
// Null contents means the user backed out of the scanner -- not an
// error, so nothing to report.
// Null contents means the user backed out of the scanner -- not an error.
val contents = result.contents ?: return@rememberLauncherForActivityResult
val settings = parseEnrollmentUri(contents.toUri())
if (settings == null) {
@@ -83,8 +82,8 @@ fun SettingsScreen(
// left-pointing arrow at the right edge, aimed across the title it sits beside.
//
// Absent rather than disabled on first run, which is the one place this app lets a
// control come and go: there is no screen underneath yet, so a Back here would not be
// a capability being withheld but a promise it could not keep.
// control come and go: there is no screen underneath yet, so a Back here would not be a
// capability being withheld but a promise it could not keep.
if (onBack != null) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
@@ -106,14 +105,11 @@ fun SettingsScreen(
OutlinedButton(
onClick = {
// Hold the camera permission before the scanner starts.
// Letting its activity ask on our behalf is what the
// library does by default, and it opens the camera without
// waiting for the answer: the first-ever scan comes up as
// a live preview with "Sorry, the Android camera
// encountered a problem" over it, and works on the second
// try. Nothing is wrong with the camera, so nothing should
// say there is.
// Hold the camera permission before the scanner starts. Letting its activity ask on
// our behalf is what the library does by default, and it opens the camera without
// waiting for the answer: the first-ever scan comes up as a live preview with
// "Sorry, the Android camera encountered a problem" over it, and works on the
// second try.
if (
context.checkSelfPermission(Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
@@ -187,11 +183,10 @@ fun SettingsScreen(
* just been granted.
*
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a
* dark-themed terminal it comes out as a photographic negative the scanner silently never matches.
* Which way round it renders is the terminal's business, not something this app should depend on.
* The mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* polarity and nothing else.
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark-
* themed terminal it comes out as a photographic negative the scanner silently never matches. The
* mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* polarity.
*/
private fun enrollmentScanOptions(): ScanOptions =
ScanOptions()
@@ -58,8 +58,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
LaunchedEffect(reloadToken) { reload() }
Column(Modifier.fillMaxSize().padding(16.dp)) {
// The heading and Back are the tab row's now; adding a machine is this tab's own work
// and stays with the list it adds to.
// The heading and Back are the tab row's now; adding a machine is this tab's own work and
// stays with the list it adds to.
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { adding = true }) { Text("Add machine") }
}
@@ -200,9 +200,8 @@ private fun SetupCard(
Column(Modifier.padding(12.dp)) {
Text(setup.name, style = MaterialTheme.typography.titleSmall)
Text(
// Not "this machine": the seeded setup is *called* that,
// and the card read "this machine / this machine". The
// line has to say something the name cannot also be.
// Not "this machine": the seeded setup is *called* that, and the card read "this
// machine / this machine".
setup.address ?: "runs where the backend does",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -237,6 +236,7 @@ private fun AddSetupDialog(
var address by remember { mutableStateOf("") }
var identity by remember { mutableStateOf("") }
var attachmentsDir by remember { mutableStateOf("") }
var modelsDir by remember { mutableStateOf("") }
var tested by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) }
@@ -251,6 +251,7 @@ private fun AddSetupDialog(
port = typedPort,
identityFile = identity.trim().ifEmpty { null },
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
modelsDir = modelsDir.trim().ifEmpty { null },
)
}
@@ -275,9 +276,8 @@ private fun AddSetupDialog(
OutlinedTextField(
value = address,
onValueChange = { address = it },
// Just the shape. What a blank one means is said once, in the text above
// this form -- repeating it here wrapped the label onto a second line and
// made this field taller than the two beside it for no information.
// Just the shape. What a blank one means is said once, in the text above this
// form -- repeating it here wrapped the label onto a second line.
label = { Text("user@host[:port]") },
singleLine = true,
)
@@ -288,14 +288,21 @@ private fun AddSetupDialog(
singleLine = true,
)
// Where a file attached from the phone lands on that machine. Blank means the
// session's own directory, which is what most people want and what needs no
// path typed on a phone.
// session's own directory, which is what most people want.
OutlinedTextField(
value = attachmentsDir,
onValueChange = { attachmentsDir = it },
label = { Text("Folder for attached files (optional)") },
singleLine = true,
)
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
// the same place this backend keeps its own downloads, read on that machine.
OutlinedTextField(
value = modelsDir,
onValueChange = { modelsDir = it },
label = { Text("Folder for models (optional)") },
singleLine = true,
)
tested?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall)
@@ -309,9 +316,8 @@ private fun AddSetupDialog(
},
dismissButton = {
Row {
// Tried before saving, so a wrong address or an
// unauthorised key is caught while this form is still on
// screen rather than at the first spawn.
// Tried before saving, so a wrong address or an unauthorised key is caught while
// this form is still on screen rather than at the first spawn.
TextButton(
enabled = !testing,
onClick = {
@@ -377,14 +383,13 @@ private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String)
/**
* Splits `user@host:port` into its two halves, with the port left null when none was typed.
*
* One field rather than two because that is how an address is written and read everywhere else --
* and because a port that is almost always 22 does not deserve a box of its own on a phone
* keyboard. Null rather than 22: the backend already decides the default, and writing 22 here would
* put a second answer to that question in a second place.
* One field rather than two because that is how an address is written and read everywhere else, and
* because a port that is almost always 22 does not deserve a box of its own on a phone keyboard.
* Null rather than 22: the backend already decides the default.
*
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
* `[::1]:22`; a bare `::1` keeps every colon, because an address with several is an address, not an
* address and a port. So the rule is: brackets, or exactly one colon followed by digits.
* `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon
* followed by digits.
*/
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
if (typed.startsWith("[")) {
@@ -9,7 +9,7 @@ import androidx.core.content.IntentCompat
*
* Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the
* share arrives before anyone has said which. [serial] makes two shares of the same thing two
* requests, for the reason [SessionOpenRequest] carries one: equal values would not recompose.
* requests, for the reason [SessionOpenRequest] carries one.
*/
data class ShareRequest(val uris: List<Uri>, val text: String?, val serial: Int)
@@ -0,0 +1,20 @@
package com.example.aiapp
/**
* A byte count at the coarsest unit that still says something, so rows stay comparable.
*
* Null at zero and below, because the screens that ask disagree about what nothing means and only
* the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no
* bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached
* transcript says "nothing cached", because a figure of none would read as a measurement.
*
* Its own file rather than the import screen's, where it started: three screens now say a size, and
* a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
*/
fun humanSize(bytes: Long): String? =
when {
bytes <= 0L -> null
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
bytes >= 1_000L -> "${bytes / 1_000L} kB"
else -> "$bytes B"
}
@@ -37,7 +37,7 @@ import kotlinx.coroutines.withContext
* The spawn screen: what to run, where to run it, and the per-kind fields.
*
* Providers and hosts both come from the server, so adding either to its config.ron shows up here
* with no app rebuild -- and because they are independent, any provider can be sent to any host.
* with no app rebuild.
*/
@Composable
fun SpawnScreen(
@@ -46,39 +46,46 @@ fun SpawnScreen(
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
// What the form is made of, and whether we have it yet. A failure here
// is not the same as a server with nothing to offer, so it must not
// reach the pickers as empty lists -- see LoadState.
// What the form is made of, and whether we have it yet. A failure here is not the same as a
// server with nothing to offer, so it must not reach the pickers as empty lists.
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
// Setup first, then one of its providers. Choosing a setup can
// invalidate the provider, so the provider is stored by name and
// resolved against the current setup rather than held as an object
// that could outlive the list it came from.
// Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the
// provider is stored by name and resolved against the current setup rather than held as an
// object that could outlive the list it came from.
var setupName by remember { mutableStateOf<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") }
var cwd by remember { mutableStateOf("") }
// "auto" rather than "manual": on a phone every ask is a round trip to
// a question card, and answering "allow Bash?" dozens of times per task
// is what this app exists to avoid. Manual stays one tap away for a
// session that warrants it.
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
var permissionMode by remember { mutableStateOf("auto") }
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
// arrives is one you can spawn at without having chosen it.
var effort by remember { mutableStateOf<String?>(null) }
var defaultsAsked by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
// Only the spawn's own failure. The fetch's lives in `options`: this
// one leaves a filled-in form worth keeping, and that one leaves
// nothing to fill in.
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) }
// Downloaded models, for a llama provider to choose between. Fetched
// beside the setups but kept separate: a Claude session needs none, so
// failing to list them must not stop the screen rendering.
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate
// from the setups: a Claude session needs none, so failing to list them must not stop the
// screen rendering. Refetched when the machine changes, because a model is a file on one
// machine -- see [fetchSetupModels].
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") }
var temperature by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
// Separate from the setups fetch below and deliberately not fatal: failing to learn the
// default must leave a screen you can still spawn from, so the picker stays on "default"
// and says so rather than the whole form refusing to draw.
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
.onSuccess { effort = it }
defaultsAsked = true
options =
try {
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
@@ -89,9 +96,6 @@ fun SpawnScreen(
} catch (e: ApiException) {
LoadState.failed(e)
}
models =
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
.getOrDefault(emptyList())
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
@@ -105,10 +109,9 @@ fun SpawnScreen(
}
Spacer(Modifier.height(16.dp))
// Nothing below is fillable until the options are here, and a
// failure to fetch them leaves no form worth showing -- so this
// reports and stops, rather than offering empty pickers under an
// error message.
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
// no form worth showing -- so this reports and stops, rather than offering empty pickers
// under an error message.
val setups =
when (val state = options) {
is LoadState.Loading -> {
@@ -122,11 +125,21 @@ fun SpawnScreen(
is LoadState.Loaded -> state.value
}
val setup = setups.firstOrNull { it.name == setupName }
// Whichever machine is chosen now, asked again when that changes. The old machine's list
// is dropped first rather than left on screen: a file name from another machine looks
// exactly like one from this one.
LaunchedEffect(setup?.id) {
models = emptyList()
modelKey = null
val id = setup?.id ?: return@LaunchedEffect
models =
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
.getOrDefault(emptyList())
}
val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and
// permission modes; keying the extra fields on the kind rather
// than the provider name keeps a second Claude provider from
// needing anything here.
// Only the Claude CLI has models, a working directory and permission modes; keying the
// extra fields on the kind rather than the provider name keeps a second Claude provider
// from needing anything here.
val isClaude = current?.kind == "claude_cli"
val isLlama = current?.kind == "llama_cpp"
@@ -137,9 +150,9 @@ fun SpawnScreen(
selected = setupName,
onSelect = { name ->
setupName = name
// The provider list changes with the machine, so a name
// carried over from the previous one would be a selection
// that isn't in the picker. Take that machine's first.
// The provider list changes with the machine, so a name carried over from the
// previous one would be a selection that isn't in the picker. Take that machine's
// first.
providerName =
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
},
@@ -150,13 +163,13 @@ fun SpawnScreen(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// The address belongs to the setup above it, not to the
// provider label below; without this they read as one block.
// The address belongs to the setup above it, not to the provider label below; without
// this they read as one block.
Spacer(Modifier.height(8.dp))
}
// Only what this machine actually has. A setup with none says so
// rather than showing an empty row that reads as a failure.
// Only what this machine actually has. A setup with none says so rather than showing an
// empty row that reads as a failure.
if (setup != null && setup.providers.isEmpty()) {
Text(
"\"${setup.name}\" has no providers configured.",
@@ -183,22 +196,21 @@ fun SpawnScreen(
)
if (isLlama) {
// A llama session names one of the models this backend has
// downloaded, so the choice is that list rather than free
// text -- there is nothing sensible to type here, and a name
// that is not on disk is a session that cannot start.
// A llama session names one of the models on the machine it will run on, so the
// choice is that list rather than free text -- a name that is not on that machine's
// disk is a session that cannot start.
if (models.isEmpty()) {
Text(
"No models downloaded yet. Get one from the Models screen first.",
"No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
"to the backend; another machine needs the file put there itself.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Model",
// The file, not the whole key: the repository is the
// same for every quantisation of a model, so the file
// name is what tells two of them apart.
// The file, not the whole key: the repository is the same for every
// quantisation of a model, so the file name is what tells two of them apart.
options = models.map { it.file },
selected = models.firstOrNull { it.key == modelKey }?.file,
onSelect = { file -> modelKey = models.first { it.file == file }.key },
@@ -261,6 +273,19 @@ fun SpawnScreen(
selected = permissionMode,
onSelect = { permissionMode = it },
)
Spacer(Modifier.height(16.dp))
// Says what it does to *later* spawns as well, because it does: the level chosen here
// is stored as the default, which is the whole way that default is set. A picker that
// quietly changed a global would be the same control with the fact left out.
ChipGroup(
label = "Thinking (kept as the default for new sessions)",
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
// The CLI's own default is a level in the list, so this cannot be a one-way trip.
// Disabled-looking until the server has answered, for the reason above.
selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null,
onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } },
)
}
Spacer(Modifier.height(24.dp))
@@ -278,15 +303,18 @@ fun SpawnScreen(
try {
val spawned =
withContext(Dispatchers.IO) {
// Stored before the spawn and not after it: choosing a level is
// an intent about new sessions in general, so a spawn that then
// fails must not also lose the choice. Non-fatal for the same
// reason the fetch above is -- the session is what was asked for.
if (isClaude) {
runCatching { setDefaultEffort(settings, effort) }
}
spawnSession(
settings,
// The id, not the label: labels are
// editable and the server resolves by
// id.
// Non-null here: `chosen` came from
// `setup`'s own provider list, so
// reaching this point proves there was
// a setup to take it from.
// The id, not the label: labels are editable and the server
// resolves by id. Non-null here, since `chosen` came from
// `setup`'s own provider list.
setup = setup.id,
provider = chosen.name,
title = title.trim(),
@@ -294,9 +322,9 @@ fun SpawnScreen(
if (isLlama) modelKey else model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
// Sent only when set, so blank means
// "whatever llama.cpp does by default"
// rather than a zero.
effort = effort.takeIf { isClaude },
// Sent only when set, so blank means "whatever llama.cpp does
// by default" rather than a zero.
params =
buildMap {
if (isLlama) {
@@ -17,14 +17,13 @@ const val RECONNECT_DELAY_MS = 1500L
* One server-sent-events connection, framed.
*
* The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank
* line ends the frame, comments (keep-alives) start with `:`, and a frame is either named with no
* payload or a payload with no name. Two screens follow two different streams — a session's
* transcript and what a machine's import list is doing — and neither should be re-deriving that.
* line ends the frame, comments start with `:`, and a frame is either named with no payload or a
* payload with no name. Two screens follow two different streams and neither should re-derive that.
*
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
* cancellation path it disconnects the socket, which unblocks the read, and [run] then returns
* rather than throwing, so a deliberate close is not reported as a connection error. Reconnecting
* belongs to the caller, which is the only one that knows where to resume from.
* cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns
* rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where
* to resume from.
*/
class Sse(private val settings: ServerSettings) {
@Volatile private var connection: HttpURLConnection? = null
@@ -38,19 +37,16 @@ class Sse(private val settings: ServerSettings) {
/**
* Follows the stream at [path], handing each frame to [onFrame] as its name (null for an
* ordinary data frame) and its payload. The path is given here rather than at construction
* because a caller that reconnects usually resumes from somewhere new -- a cursor it has
* advanced past -- and that lives in the query string.
* because a caller that reconnects usually resumes from somewhere new.
*
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
* stream is live, and the only honest thing to clear a previous failure on: clearing on the
* first *event* instead left an idle stream displaying a connection error it had already
* recovered from, indefinitely.
* first *event* instead left an idle stream displaying an error it had already recovered from.
*/
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
// Opening is inside the try, not before it. Everything this method can fail at owes the
// caller the same kind of failure -- both callers retry an [ApiException] and let anything
// else reach the top of the app -- and a connection that could not even be constructed
// used to escape as a raw `IOException` from a line no `catch` covered.
// caller the same kind of failure, and a connection that could not even be constructed used
// to escape as a raw `IOException` from a line no `catch` covered.
var connection: HttpURLConnection? = null
try {
connection =
@@ -93,7 +89,7 @@ class Sse(private val settings: ServerSettings) {
if (!closed) {
throw ApiException(
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
e,
cause = e,
)
}
} finally {
@@ -1,70 +0,0 @@
package com.example.aiapp
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
/**
* Dragging the screen to the right to step back to the one behind it.
*
* The platform's own back gesture is a swipe from the very edge, and only from there; on a phone
* held in one hand the way back from a session is either that narrow strip or the arrow at the top
* left, which is the far corner from the thumb. This is the same movement from anywhere on the
* screen.
*
* **It loses every argument.** The gesture is a plain horizontal [draggable] on the outside of the
* screen, so anything inside that wants horizontal drags has already taken them by the time this
* would see them: pointer events reach the innermost node first, and a drag a child has consumed
* never crosses this modifier's touch slop. That is what keeps a wide code fence, a table scrolled
* sideways or a text selection working -- they are the components the reader meant, and this is
* only what is left over. Vertical drags are not its orientation, so the transcript scrolls
* untouched.
*
* The screen follows the finger rather than jumping at the end, because a gesture with no feedback
* cannot be aborted: the reader has to be able to see it starting and change their mind. Released
* short of [SWIPE_BACK_TRAVEL] it slides back and nothing happens. Right rather than left, and only
* right, since there is nothing forward of these screens to go to.
*/
@Composable
fun Modifier.swipeBack(onBack: () -> Unit): Modifier {
val offset = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val travel = with(LocalDensity.current) { SWIPE_BACK_TRAVEL.toPx() }
return draggable(
state =
rememberDraggableState { delta ->
// Rightward only: a leftward drag stays at zero rather than lifting the
// screen off its left edge, which would look like a gesture that does
// something and does not.
scope.launch { offset.snapTo((offset.value + delta).coerceAtLeast(0f)) }
},
orientation = Orientation.Horizontal,
onDragStopped = {
if (offset.value >= travel) {
onBack()
// Straight back rather than animated: the screen this was moving is being
// replaced, and animating it home first would show the old one sliding back
// into place after the new one had arrived.
offset.snapTo(0f)
} else {
offset.animateTo(0f)
}
},
)
// Read inside the block, so following the finger is a draw-phase change and costs no
// recomposition of the screen being dragged.
.graphicsLayer { translationX = offset.value }
}
/** How far the screen has to be pulled for letting go to mean "back" rather than "never mind". */
private val SWIPE_BACK_TRAVEL: Dp = 96.dp
@@ -44,16 +44,14 @@ private object Mocha {
*
* Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link*
* -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike
* is a preference, not a contract, and the moment one wants a different accent the shared version
* becomes a thing to fight rather than a thing to use.
* is a preference, not a contract.
*
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
* Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the
* page is Base, a component's outlined card stays Base beside it, and a project's card is Surface
* 0: one visible step up, which is the whole of what the nesting has to say.
* Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside
* it, and a project's card is Surface 0: one visible step up, which is the whole of what the
* nesting has to say.
*
* Accents on this palette are light, so anything filled with one takes Crust for its text rather
* than the near-white the roles default to.
* Accents on this palette are light, so anything filled with one takes Crust for its text.
*/
val AiAppColors =
darkColorScheme(
@@ -94,10 +92,8 @@ val AiAppColors =
* What a session is doing, said in colour.
*
* Here rather than beside each screen that shows a status. These were separate literals in two
* other files -- an amber, a green and a red picked off Material's defaults -- so the same state
* was a slightly different colour depending which screen you looked at, and none of them belonged
* to this palette at all. A colour that carries meaning is part of the scheme, not a value typed
* where it happened to be needed.
* other files, so the same state was a slightly different colour depending which screen you looked
* at. A colour that carries meaning is part of the scheme, not a value typed where it was needed.
*/
val runningColor: Color
@Composable get() = Mocha.Green
@@ -107,7 +103,7 @@ val runningColor: Color
*
* The scheme's error colour, and deliberately not "the same red as a destructive button" even
* though it is the same red. They are the same red for different reasons, and a state is not an
* action -- nothing here is a button.
* action.
*/
val failedColor: Color
@Composable get() = MaterialTheme.colorScheme.error
@@ -116,10 +112,9 @@ val failedColor: Color
* About the session rather than about the task: a command, and the compaction one of them starts.
*
* Its own colour because it is its own kind of work. Everything else a session does is progress
* through what was asked of it; this is the session acting on itself -- rewriting what it
* remembers, taking a new name -- and none of it appears in the transcript as an answer to
* anything. A reader who has learned that blue means "not stuck, but not replying to you either"
* has learned the thing that distinguishes it from a session that has hung.
* through what was asked of it; this is the session acting on itself, and none of it appears in the
* transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not
* replying to you either" has learned what distinguishes it from a session that has hung.
*/
val commandColor: Color
@Composable get() = Mocha.Blue
@@ -128,10 +123,9 @@ val commandColor: Color
* A clear: the conversation taken out of what the session is given.
*
* Red because of what it does, not because anything went wrong -- somebody asked for this, and a
* deliberate choice is not a problem to report. It is the same red as [failedColor] and [stopColor]
* for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a
* button, it is the mark left where something was taken away. The reader never has to tell the
* three apart, because no two of them can appear as the same kind of thing.
* deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a
* third reason: this is neither a fault nor a button, it is the mark left where something was taken
* away. No two of the three can appear as the same kind of thing.
*/
val clearedColor: Color
@Composable get() = Mocha.Red
@@ -148,9 +142,9 @@ val warningColor: Color
* The fill of a progress bar that is only reporting how far along something is.
*
* Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary
* made it the loudest thing on a screen the reader opened to do something else. A download, or a
* compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and
* that one is [quotaColor].
* made it the loudest thing on a screen the reader opened to do something else. A download has no
* limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is
* [quotaColor].
*/
val progressColor: Color
@Composable get() = Mocha.Blue
@@ -158,15 +152,13 @@ val progressColor: Color
/**
* The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red.
*
* One function rather than the same `when` written beside each bar, because the whole point of
* colouring by consequence is that the reader learns the step once -- two bars showing the same 80%
* in different colours teaches nothing except that the colour cannot be trusted. It reads as a
* difference in degree, which is all colour can carry: the states that differ in *kind* from this
* -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere,
* because a reader has no way to tell those from an ordinary low number by colour alone.
* One function rather than the same `when` written beside each bar, because the point of colouring
* by consequence is that the reader learns the step once. It reads as a difference in degree, which
* is all colour can carry: the states that differ in *kind* -- a window nobody could read, a
* machine that meters nothing -- are said in words elsewhere.
*
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
* without each converting it first and one of them getting it wrong by a factor of a hundred.
* without one of them getting it wrong by a factor of a hundred.
*/
@Composable
fun quotaColor(percent: Double): Color =
@@ -185,13 +177,11 @@ private const val OVER_LIMIT_PERCENT = 90.0
/**
* The surface verbatim text sits on: a command, a tool's output, a code block in a reply.
*
* The darkest value in the palette rather than a step up from the page, and that is the whole point
* -- everything else on this screen is somebody's prose, and this is what a machine was handed and
* The darkest value in the palette rather than a step up from the page, and that is the point --
* everything else on this screen is somebody's prose, and this is what a machine was handed and
* what it said back, character for character. Crust sits *below* Base, so the same colour reads as
* one clear step down both on the page, where a reply is drawn, and on a card, where a tool call
* is; a tint chosen upwards has to be picked twice and still collides with the card it lands on.
* The renderer's default code background was `surfaceVariant`, which is exactly a card's own fill
* -- so a code block inside a tool call had no background at all.
* one clear step down both on the page and on a card; a tint chosen upwards has to be picked twice
* and still collides with the card it lands on.
*
* One colour for all three, so "this is verbatim" is learnable once.
*/
@@ -202,11 +192,10 @@ val rawSurface: Color
* Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette].
*
* Here with the rest of the palette rather than beside the code that highlights: the colours a
* fence is drawn in are the same accents every other coloured thing in the app already uses, and
* splitting them out would make code the one surface whose palette came from somewhere else.
* fence is drawn in are the same accents every other coloured thing already uses.
*
* Not a composable, because [highlight] runs off the drawing thread; these colours never vary with
* the theme.
* Not a composable, because [highlight] runs off the drawing thread; these never vary with the
* theme.
*/
fun catppuccinSyntax(): SyntaxPalette =
SyntaxPalette(
@@ -227,8 +216,7 @@ fun catppuccinSyntax(): SyntaxPalette =
* already made for every other blue on the screen.
*
* Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which
* is upstream's choice and not an omission here. A program that uses bright red to mean something
* other than red is relying on a distinction its own terminal may not draw either.
* is upstream's choice and not an omission here.
*
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
* video needs to know what it is reversing against.
@@ -263,14 +251,12 @@ fun ansiPalette(): AnsiPalette =
*
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
* code block or a tool's output, on Crust, the same 40% composites to a barely-there smudge, so
* selecting a line of code looks like nothing happened even though the selection is there and
* copies correctly.
* code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of
* code looks like nothing happened even though it copies correctly.
*
* Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that
* means something must carry its own contrast instead of borrowing it from the surface it happens
* to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill
* starts competing with the syntax colours it sits behind, which are the thing being read.
* Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only
* as far as it takes to read on the darkest of them -- past this the fill starts competing with the
* syntax colours it sits behind.
*/
val AiAppSelectionColors =
TextSelectionColors(
@@ -288,11 +274,10 @@ val linkColor: Color
* A list's markers: the bullets and numbers down its left edge.
*
* The scheme's secondary accent rather than the text colour, because a marker is structure rather
* than words: coloured, the items of a list can be counted without reading them, and a nested list
* reads as a shape before it reads as text. Lavender is not one of the colours that mean something
* here -- green, red, peach and yellow are states and actions -- and it is the same at every depth,
* since depth is said by the glyph and the indent; a colour per depth would make a difference in
* degree look like one in kind.
* than words: coloured, the items of a list can be counted without reading them. Lavender is not
* one of the colours that mean something here, and it is the same at every depth, since depth is
* said by the glyph and the indent -- a colour per depth would make a difference in degree look
* like one in kind.
*/
val listMarkerColor: Color
@Composable get() = Mocha.Lavender
@@ -305,11 +290,9 @@ val overLimitColor: Color
* The composer's buttons, coloured by what pressing one does rather than by where it sits.
*
* Green makes something happen now, blue makes it happen later, orange takes back what is in
* flight, red ends the process. The near-collisions with the states above are deliberate and worth
* naming rather than collapsing: [runningColor] is green because a session is working,
* [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session
* is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell
* them apart, because nothing here is a state and nothing there is pressable.
* flight, red ends the process. The near-collisions with the states above are deliberate: those are
* *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is
* a state and nothing there is pressable.
*/
val sendColor: Color
@Composable get() = Mocha.Green
@@ -322,9 +305,8 @@ val queueColor: Color
* Interrupting the running turn: the work stops and the session stays.
*
* Orange rather than red because of how much it takes: only what is in flight. The process is still
* there holding the conversation, and the next message starts a turn as though nothing had
* happened. Red is spent on [stopColor], which is the same button in the same place when what it
* would end is the session's process.
* there holding the conversation. Red is spent on [stopColor], which is the same button in the same
* place when what it would end is the session's process.
*/
val pauseColor: Color
@Composable get() = Mocha.Peach
@@ -347,8 +329,7 @@ val startColor: Color
*
* The content colour is stated here beside the fill rather than inherited. A semantic colour has to
* carry its own contrast: these fills are fixed whatever the surface under them does, so the theme
* will not change to rescue a foreground that stops being readable on one of them. Crust is what
* every accent on this palette takes, which is the same reason `onPrimary` is Crust above.
* will not change to rescue a foreground that stops being readable on one of them.
*/
@Composable
fun actionButtonColors(fill: Color): ButtonColors =
@@ -16,11 +16,10 @@ import org.json.JSONObject
/**
* A tool call's input, read rather than dumped.
*
* Every tool's input arrives as JSON, and showing it raw makes the reader parse `{"command":"…",
* "timeout":120000}` themselves to find the one line they care about. So the fields that carry the
* meaning are pulled out -- the command a shell will run, what it is for, how long it may take --
* and anything left over is still shown, because dropping a field would be claiming the tool has no
* other input when it might.
* Every tool's input arrives as JSON, and showing it raw makes the reader parse
* `{"command":"…","timeout":120000}` themselves to find the one line they care about. So the fields
* that carry the meaning are pulled out, and anything left over is still shown, because dropping a
* field would be claiming the tool has no other input when it might.
*/
data class ToolInput(
/** The thing that will actually be run or read, if this tool has one. */
@@ -30,8 +29,8 @@ data class ToolInput(
/** The tool's own one-line summary, when it wrote one. */
val description: String?,
/**
* How long the call may take, in the largest units it fits ([formatMillis]). Shown apart
* because it is a limit on the call rather than part of what the call does.
* How long the call may take, in the largest units it fits. Shown apart because it is a limit
* on the call rather than part of what the call does.
*/
val timeout: String?,
/** Everything else, as `name: value` lines. Never dropped. */
@@ -47,7 +46,7 @@ data class ToolInput(
*
* A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them
* from being the special case that gets its own code path. Unknown tools fall through to "no
* subject, everything is rest", which is what the card always did.
* subject, everything is rest".
*/
private val SUBJECTS: Map<String, Pair<String, Language?>> =
mapOf(
@@ -68,8 +67,8 @@ fun parseToolInput(tool: String, input: String): ToolInput {
try {
JSONObject(input)
} catch (_: org.json.JSONException) {
// Not an object: older transcripts and some tools send a bare
// string. It is still the input, so it is still shown.
// Not an object: older transcripts and some tools send a bare string. It is still the
// input, so it is still shown.
return ToolInput(
null,
null,
@@ -100,9 +99,9 @@ fun parseToolInput(tool: String, input: String): ToolInput {
/**
* A tool call's input: its subject highlighted, then whatever else it carried.
*
* On the dark surface every verbatim thing in the app sits on -- see [RawBlock]. Drawn as nothing
* at all when the call carried neither, rather than as an empty block: a tinted rectangle with
* nothing in it is a rendering fault, and it is the shape a tool with no input actually has.
* On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the
* call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a
* rendering fault.
*
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
@@ -113,11 +112,11 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
if (parsed.subject == null && parsed.rest.isEmpty()) return
RawBlock(modifier) {
parsed.subject?.let { subject ->
// Not wrapped: a wrapped command hides where its arguments end,
// and the long one is the one being read closely.
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely.
Text(
// Not cached: a tool's subject is one command line, which lexes in microseconds
// -- the cache exists for a fence with two hundred lines in it.
// Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it.
remember(subject, parsed.language) { highlight(subject, parsed.language) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
@@ -42,14 +42,11 @@ import androidx.compose.ui.unit.dp
* the transcript's own order is what paging and the event stream depend on, and one screen's idea
* of "these belong together" must not reach back into it.
*
* Immutable, and said so, because Compose cannot tell.
*
* A row is a value: it is rebuilt from the transcript rather than edited, and two rows describing
* the same events are equal. Compose infers stability from a class's fields, and a `List` field --
* which several of these carry -- makes it assume the worst, so every composable taking one
* recomposed whenever anything above it did. A page of history landing recomposed all 148 loaded
* rows including the markdown inside them, measured as 701 compositions for 148 rows in one scroll,
* and that is what a page landing costs on top of the fetch itself.
* Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather
* than edited, and two rows describing the same events are equal. Compose infers stability from a
* class's fields, and a `List` field -- which several of these carry -- makes it assume the worst,
* so a page of history landing recomposed all 148 loaded rows including the markdown inside them,
* measured as 701 compositions for 148 rows in one scroll.
*
* The promise this makes is real and has to stay true: nothing here is mutated after it is built.
*/
@@ -59,16 +56,12 @@ sealed class TranscriptRow {
* This row's identity in the list, which must survive everything that can happen to the row.
*
* The list is keyed by this so that inserting a new message at one end, or a page of history at
* the other, moves the rows and not the reader. That makes it the load-bearing value on this
* screen: when a key changes, the list loses its anchor and the transcript steps under whoever
* is reading it.
* the other, moves the rows and not the reader. When a key changes, the list loses its anchor
* and the transcript steps under whoever is reading it.
*
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
* and it is the *same* value whether the run is drawn as one card or as a group. A lone call
* that gains a neighbour becomes a group without changing identity, which is the case a
* seq-based key got wrong: the row the reader was looking at was replaced rather than updated.
* Which value that is belongs to the item ([TranscriptItem.key]), not to a `when` here: a row
* is one item and the item is what knows what it is called.
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
* that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
*/
abstract val key: Any
@@ -76,12 +69,9 @@ sealed class TranscriptRow {
* Where this row starts in the transcript: the sequence number of the oldest event behind it.
*
* Separate from [key], and deliberately so. [key] is the list's identity and is a display
* decision -- a tool row is named after its run, and a run takes its name from whichever call
* was first when it was folded, which changes as pages arrive. A seq is the server's own
* numbering: it is assigned once, never moves, and means the same thing to every device. So
* anything that has to point at a place in the conversation and still find it later -- a saved
* scroll position is the one -- points with this, and anything that has to identify a row
* within one composition uses [key].
* decision; a seq is the server's own numbering, assigned once and meaning the same thing to
* every device. So anything that has to point at a place in the conversation and still find it
* later -- a saved scroll position -- points with this.
*/
abstract val startSeq: Long
@@ -133,8 +123,7 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
// Adjacency is the same answer most of the time and a worse one at the edges: a call
// arriving next to an existing run, or a page of history arriving in front of one, both
// change which call is *first*, and a group named after its first member is a different
// group every time that happens.
// change which call is *first*.
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
run += item
} else {
@@ -152,18 +141,14 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
* What says the calls belong together is the surface behind them, which is the one cue rather than
* two half-cues -- rounded to the same corner every other card in the app has, so a group reads as
* one object rather than as a square patch behind round things. The calls sit on it inset by
* [GROUP_INSET], which is the container's own padding rather than an indent: they are the same rows
* they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a
* notch.
* [GROUP_INSET], which is the container's own padding rather than an indent.
*
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
* the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without
* separating them. See [connectedShape].
* the run reads as one thing broken into its parts; see [connectedShape].
*
* It closes from either end. A long group's header scrolls off while its last call is still on
* screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. The
* bar at the foot is the same height as the heading at the top, so the surface the calls sit on is
* as thick below them as above.
* screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the
* same height as the heading at the top.
*/
@Composable
fun ToolGroup(
@@ -171,8 +156,7 @@ fun ToolGroup(
expanded: Boolean,
/**
* Where it was pressed is the row's business rather than the control's -- a group has a control
* at each end, and only the row knows where its own ends are, so the row records the touch
* itself and this just says that one happened.
* at each end, and only the row knows where its own ends are.
*/
onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean,
@@ -222,8 +206,8 @@ fun ToolGroup(
)
}
}
// Shutting it from here anchors the other end: the reader is at the bottom of a long
// group, and what they are looking at is what follows it.
// Shutting it from here anchors the other end: the reader is at the bottom of a long group,
// and what they are looking at is what follows it.
CollapseBar(barHeight, onToggle)
}
}
@@ -232,8 +216,8 @@ fun ToolGroup(
* The height of a group's heading, and so of the bar at its foot.
*
* Derived from the type the heading is set in rather than written down, because the two have to
* match and a pair of numbers chosen to look equal stops being equal the moment either the style or
* the density changes. Taking the line height also means the heading cannot be clipped by it.
* match and a pair of numbers chosen to look equal stops being equal the moment the density
* changes.
*/
@Composable
private fun groupBarHeight(): Dp {
@@ -242,10 +226,9 @@ private fun groupBarHeight(): Dp {
}
/**
* The bottom half of a group's toggle: an arrow back up to its heading.
*
* Given the heading's height rather than padded to something that looks close, so the surface the
* calls sit on is the same thickness at both ends. See [groupBarHeight].
* The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height
* rather than padded to something that looks close, so the surface the calls sit on is the same
* thickness at both ends.
*/
@Composable
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
@@ -266,8 +249,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
* does not.
*
* Written once and given an index rather than branched at each end, because a stack has three cases
* that are one rule -- and the middle one is the case a hand-written first/last pair gets wrong
* when a run turns out to have three calls in it.
* that are one rule -- and the middle one is what a hand-written first/last pair gets wrong.
*/
@Composable
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
@@ -294,12 +276,10 @@ private val GROUP_GAP = 2.dp
* One tool call.
*
* Closed, it is a single line: the tool's name and what the call is for. The command itself is not
* on it, because a wrapped command turns one row into four and a run of them into a wall -- and the
* name plus the intent is what somebody scanning the transcript is reading for.
* on it, because a wrapped command turns one row into four and a run of them into a wall.
*
* Open, it shows the command, whatever else the input carried, and the output. The timeout sits at
* the top right: it is a limit on the call rather than part of what the call does, and it is worth
* seeing beside the command it constrains rather than buried in the fields below it.
* the top right: it is a limit on the call rather than part of what the call does.
*
* A call waiting on permission is shown open whatever the reader last chose, since the command is
* the thing being decided and a row saying only "Bash" cannot be decided on.
@@ -342,10 +322,9 @@ fun ToolCard(
)
} ?: Spacer(Modifier.weight(1f))
}
// A spinner says the machine is working. While this call is waiting on an
// answer the machine is doing nothing at all -- the turn is stopped on the
// person reading it -- so it says whose move it is instead, in the colour this
// app uses everywhere for that.
// A spinner says the machine is working. While this call is waiting on an answer
// the machine is doing nothing at all -- the turn is stopped on the person reading
// it -- so it says whose move it is instead.
if (deciding) {
Spacer(Modifier.width(8.dp))
Text(
@@ -370,9 +349,9 @@ fun ToolCard(
modifier = Modifier.padding(top = 4.dp),
)
}
// Everything AskUserQuestion carries is the questions, and those are drawn
// below as something answerable; dumping the same JSON above them would be the
// decision stated twice, once unreadably.
// Everything AskUserQuestion carries is the questions, and those are drawn below as
// something answerable; dumping the same JSON above them would be the decision
// stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
}
@@ -381,14 +360,12 @@ fun ToolCard(
Text("Output", style = MaterialTheme.typography.labelSmall)
// What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is
// prose -- a directory listing, a diff, a table of numbers -- and a
// proportional font silently destroys the alignment that carried the meaning.
// prose, and a proportional font silently destroys the alignment that carried
// the meaning.
//
// Its terminal styling applied and the rest of the escapes taken out, since
// what a shell prints is written for a terminal: colour is often the whole of
// what a diff or a test run is saying, and the sequences that carry it are
// unreadable drawn verbatim. Remembered against the text, so a card that is
// open through a scroll parses once. See [ansiStyled].
// Its terminal styling applied and the rest of the escapes taken out: colour is
// often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() }
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
RawBlock(Modifier.padding(top = 2.dp)) {
@@ -400,10 +377,8 @@ fun ToolCard(
}
}
}
// Shown open or closed. A call that produced a picture is one
// whose result *is* the picture, and a row that hides it says
// less than the one line it replaced -- unlike a command, which
// is what the closed line already summarises.
// Shown open or closed. A call that produced a picture is one whose result *is* the
// picture, and a row that hides it says less than the one line it replaced.
tool.images.forEach { ref -> image(ref) }
if (tool.asks.isNotEmpty()) {
if (tool.tool == ASK_USER_QUESTION) {
@@ -428,11 +403,10 @@ private fun PermissionAsk(
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) {
// What was pressed, before the answer has been round-tripped. Two bare words with no submit
// step -- unlike a question card, where the answer is several choices and worth reviewing --
// so the press has to be its own acknowledgement or the row sits unchanged for a round trip
// and reads as having missed the tap. Cleared when the request settles: by then either the
// answer is in `ask.answers` and the mark stands on a measurement, or it failed and the
// buttons come back rather than leaving a decision marked that nothing recorded.
// step -- unlike a question card, where the answer is worth reviewing -- so the press has to be
// its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request
// settles: by then either the answer is in `ask.answers`, or it failed and the buttons come
// back.
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
Spacer(Modifier.height(8.dp))
Text(
@@ -442,8 +416,7 @@ private fun PermissionAsk(
)
// Answered or not, the options stay and the one that was taken is marked -- see
// [AskedQuestion], which is the same rule on the question card. A permission is where it
// matters most: "Answered: Deny" alone does not say that Allow was the alternative, and
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
// matters most: "Answered: Deny" alone does not say that Allow was the alternative.
val settled = ask.answers.isNotEmpty()
AnswerOptions(
ask.options,
@@ -0,0 +1,27 @@
package com.example.aiapp
/**
* Where one transcript lives: a session's own, or one of its subagents'.
*
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
*/
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
val urlPath: String
get() =
if (subagentId == null) "sessions/$sessionId"
else "sessions/$sessionId/subagents/$subagentId"
/**
* Where this transcript's cache lives on the phone, relative to the cache root.
*
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
* session's cache directory takes its subagents' with it -- the same one-way door the server's
* own storage describes.
*/
val cachePath: String
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
}
@@ -0,0 +1,596 @@
package com.example.aiapp
import android.util.Log
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.io.RandomAccessFile
/**
* This phone's copy of the transcripts it has already been sent, so reopening a session does not
* download it again.
*
* What is stored is the server's own JSON for one event per line, in transcript order. Reading the
* cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a
* fetched one cannot draw differently, and an event type this build does not know keeps every field
* it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a
* rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`.
*
* See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it:
* 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the
* cache is a copy and is never inferred, folded or edited here;
* 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache;
* 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a
* cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched
* again.
*
* A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs
* under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line
* is the sequence number and whether the line is a streamed delta, both read with a regex. A line
* it cannot read that way is treated as damage. [warn] is where failures are said for the same
* reason.
*/
class TranscriptCache(
private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/**
* The cache for one transcript, whether or not anything has been stored for it yet.
*
* A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so
* deleting the session (below) takes its subagents' caches with it -- there is no separate
* purge for one.
*/
fun session(address: TranscriptAddress): SessionCache =
SessionCache(File(root, address.cachePath), warn)
/**
* Deletes every session directory not in [ids], called after a successful list fetch. The path
* out for a session deleted on another device: nothing here would otherwise hear about it, and
* unlike a draft's few bytes what it leaves behind is megabytes.
*/
fun retainOnly(ids: Set<String>) =
guardIo(Unit, warn) {
sessionDirs().forEach { if (it.name !in ids) it.deleteRecursively() }
}
/**
* Deletes least-recently-touched session directories, never [keep], until the whole of this
* server's cache is under [budget]. Least-recently-touched rather than largest: what a reader
* is likely to open again is what they opened last, and evicting the big ones first would empty
* the cache for exactly the conversations it exists for.
*/
fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) =
guardIo(Unit, warn) {
val dirs = sessionDirs().sortedBy { it.lastModified() }
var total = dirs.sumOf { sizeOf(it) }
for (dir in dirs) {
if (total <= budget) break
if (dir.name == keep) continue
val was = sizeOf(dir)
if (dir.deleteRecursively()) total -= was
}
}
fun purgeAll() = guardIo(Unit, warn) { root.deleteRecursively() }
private fun sessionDirs(): List<File> = root.listFiles()?.filter { it.isDirectory }.orEmpty()
}
/**
* How much of this phone's cache directory all of one server's transcripts may take. A dozen of the
* largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone.
* A number to revisit against real use rather than a measurement of anything.
*/
const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000
/**
* What the newest cached line says, which is what the probe checks against the server. Both halves
* are wanted together: the seq is what the request asks about, and the line is what its answer is
* compared with.
*/
data class CachedTail(val seq: Long, val line: String)
/**
* One session's cached lines, as a directory of chunks.
*
* A chunk is a set of lines *and a claim about what they cover*, and the two are not the same
* thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the
* run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the
* 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range
* in the file's name:
* ```
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run <first>-open.raw.jsonl the
* live run; end is its last line's seq + 1
* ```
*
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending
* at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because
* the gap is usually closed by paging back through it, but nothing is served across one.
*
* **The newest chunk is always raw**, which is what makes the stream cursor and the probe well
* defined. It holds by construction (the opening window and every stream frame are raw) and is
* checked on read: a `.rows` chunk at the newest end can only mean this app died between closing
* one live run and opening the next, and it discards the session.
*
* Nothing here is load-bearing. Every operation that touches the disk answers as though the cache
* were empty when it cannot, and a write failure disables writing for the rest of this instance's
* life so that a full disk costs one log line rather than one per delta.
*
* Every operation is synchronized, because two of them really do run at once: the stream appends
* live events from its own IO thread while a reader scrolling back reads pages from another. What
* it buys is that the open chunk's name, its end and its writer are never read half-rotated.
*/
class SessionCache(
private val dir: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/** Set by the first write that fails: a second would fail the same way, once per delta. */
private var disabled = false
/**
* The open chunk's writer, its file, and the seq that chunk now ends at.
*
* Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of
* times a second while a reply streams. What that costs is the unflushed tail on a crash, which
* is safe: a shorter cache is a longer catch-up, never a wrong one.
*/
private var writer: BufferedWriter? = null
private var openFile: File? = null
private var openEnd: Long = 0
/**
* The newest line of the suffix, or null when there is none or the newest chunk is not raw.
*
* This is the cursor the live stream would resume from, so it is also what has to be shown to
* still be the server's own line before anything is resumed from it.
*/
@Synchronized
fun tail(): CachedTail? =
guard(null) {
val newest = suffix().lastOrNull() ?: return@guard null
var found: CachedTail? = null
eachLine(newest) { line ->
found = CachedTail(seqOf(line)!!, line)
false
}
found
}
/** The newest [limit] lines of the suffix, oldest first -- the opening window. */
@Synchronized
fun newest(limit: Int): List<String> =
guard(emptyList()) {
val taken = ArrayDeque<String>()
for (chunk in suffix().asReversed()) {
if (taken.size >= limit) break
eachLine(chunk) { line ->
taken.addFirst(line)
taken.size < limit
}
}
taken.toList()
}
/**
* The page of lines before [before], oldest first, or null when the cache cannot answer.
*
* Null is a miss -- the suffix does not cover the ground immediately below [before] -- and
* means the server has to be asked. Deliberately not an empty list: an empty page is how the
* screen is told it has reached the start of the conversation, and a cache saying that of
* history it merely does not hold would stop the transcript scrolling back for good.
*
* [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open
* leaves behind is in the middle of the live run, so a cache that could only answer at a
* boundary would send the very first backwards page to the server and, since that page would
* overlap the run, keep none of it.
*
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`.
* The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of
* its first delta either way.
*/
@Synchronized
fun page(before: Long, limit: Int, rows: Boolean): List<String>? =
guard(null) {
val suffix = suffix()
val newest = suffix.lastOrNull() ?: return@guard null
// Above what is held, or at or below where it starts: either way the run the caller is
// scrolling into is not continuous with this one, and only the server has it.
if (before > newest.end || before <= suffix.first().first) return@guard null
val taken = ArrayDeque<String>()
var counted = 0
var inRun = false
var wanting = true
for (chunk in suffix.asReversed()) {
if (!wanting) break
if (chunk.first >= before) continue
eachLine(chunk) { line ->
// The page is what is *before* the cursor; the rows at or above it are already
// on screen.
if (seqOf(line)!! >= before) return@eachLine true
if (rows) {
val delta = isDelta(line)
// Stop only between rows: a delta continuing the run being gathered is part
// of a row already counted, and breaking on it would drop the half of that
// row already taken.
if (counted >= limit && !(delta && inRun)) wanting = false
else {
if (!delta || !inRun) counted++
inRun = delta
}
} else if (taken.size >= limit) {
wanting = false
}
if (wanting) taken.addFirst(line)
wanting
}
}
taken.toList()
}
/**
* The `end` of the nearest chunk at or below [before], which is the floor a fetched page is
* asked with so that it stops where this phone's copy starts. Null when there is no such chunk.
*
* Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that
* the gap is closed with exactly the bytes it is wide.
*/
@Synchronized
fun coveredUpTo(before: Long): Long? =
guard(null) { chunks().map { it.end }.filter { it <= before }.maxOrNull() }
/**
* Stores a fetched page covering `[first, end)`; false when it was not stored.
*
* Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced
* event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway.
* Such a page is still drawn; it is only not kept.
*
* The newest chunk is never stored through here: the opening window and every live frame go
* through [append], which is what keeps the newest chunk raw and open.
*/
@Synchronized
fun storePage(lines: List<String>, first: Long, end: Long, rows: Boolean): Boolean =
guard(false) {
if (disabled || lines.isEmpty() || end <= first) return@guard false
if (chunks().any { first < it.end && it.first < end }) return@guard false
dir.mkdirs()
val kind = if (rows) "rows" else "raw"
File(dir, "$first-$end.$kind.jsonl").writeText(lines.joinToString("\n", postfix = "\n"))
true
}
/**
* Appends one live event, which is also how a freshly fetched opening window is stored.
*
* A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a
* `reset` looks like from here -- and closes the open chunk under the end it turned out to
* have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`.
*/
@Synchronized
fun append(line: String, seq: Long) =
guard(Unit) {
if (disabled) return@guard
val writer = writerFor(seq) ?: return@guard
// Written as it arrived. A newline inside it would split one event into two unreadable
// halves, but neither source can produce one: SSE framing forbids it, and a page's
// elements are re-serialized compactly, which escapes it.
writer.write(line)
writer.write("\n")
openEnd = seq + 1
}
/**
* Flushes what [append] has buffered. Called on each `Status` event -- the boundaries of a
* turn, which is the granularity a crash may as well lose -- and when the stream closes.
*/
@Synchronized fun flush() = guard(Unit) { writer?.flush() }
/** What [purge] would discard, for the reload row in session settings. */
@Synchronized fun bytes(): Long = guard(0L) { sizeOf(dir) }
/** Marks this session as visited, which is what eviction ranks by. */
@Synchronized
fun touch() =
guard(Unit) { if (dir.isDirectory) dir.setLastModified(System.currentTimeMillis()) }
@Synchronized
fun purge() =
guard(Unit) {
closeWriter()
dir.deleteRecursively()
}
// -- chunks ------------------------------------------------------------------------------
private data class Chunk(val file: File, val first: Long, val end: Long, val open: Boolean) {
val rows: Boolean
get() = file.name.endsWith(".rows.jsonl")
}
/**
* Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored.
* Recomputed per operation rather than kept: another operation may have changed the directory.
*/
private fun chunks(): List<Chunk> {
writer?.flush()
return dir.listFiles()
.orEmpty()
.mapNotNull { file ->
val match = CHUNK_NAME.matchEntire(file.name) ?: return@mapNotNull null
val first = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null
val open = match.groupValues[2] == "open"
val end = if (open) openEndOf(file, first) else match.groupValues[2].toLongOrNull()
// A chunk covering nothing is one that was created and never written to -- an
// append whose very first write failed. It says nothing, so it is not a chunk.
if (end == null || end <= first) null else Chunk(file, first, end, open)
}
.sortedBy { it.first }
}
/**
* The open chunk's end: its last line's seq plus one, or the in-memory end while this instance
* is the one writing it.
*
* An open chunk whose last line cannot be read is this app having died mid-write. That line is
* dropped and the file truncated to the last good one, which is the one place damage is
* repaired rather than discarded: the tail of an append-only file is the only place a partial
* line can be.
*/
private fun openEndOf(file: File, first: Long): Long {
if (openFile == file && openEnd > 0) return openEnd
repairTail(file)
var end = first
eachLineBackwards(file) { _, line ->
seqOf(line)?.let { end = it + 1 }
false
}
return end
}
/**
* The contiguous run of adjacent chunks ending at the newest one, oldest first.
*
* A newest chunk that is not raw cannot happen while this code is the only writer, and means
* the directory is not to be trusted -- so the session is discarded.
*/
private fun suffix(): List<Chunk> {
val all = chunks()
var index = all.size - 1
val newest = all.lastOrNull() ?: return emptyList()
if (newest.rows) throw Damaged(newest.file)
val run = ArrayDeque<Chunk>()
run.addFirst(newest)
while (index > 0 && all[index - 1].end == run.first().first) {
index--
run.addFirst(all[index])
}
return run.toList()
}
/**
* Each line of [chunk], newest first, until [take] says stop.
*
* Backwards and lazily, because every question this cache is asked is about the newest end and
* a live run grows to the size of the conversation. Reading the file whole to answer with
* eighty lines of it is the cost the server's own reader was rewritten to stop paying.
*
* Damage anywhere but at the tail of the open chunk was not written by this code, and there is
* no honest way to say what a chunk covers with a line of it unreadable -- so it discards the
* session rather than serving what it can read.
*/
private fun eachLine(chunk: Chunk, take: (String) -> Boolean) {
eachLineBackwards(chunk.file) { _, line ->
if (seqOf(line) == null) throw Damaged(chunk.file)
take(line)
}
}
// -- writing -----------------------------------------------------------------------------
/** The writer for the chunk [seq] belongs in, opening or rotating one as it has to. */
private fun writerFor(seq: Long): BufferedWriter? {
writer?.let { held ->
if (seq == openEnd) return held
if (seq < openEnd) return null
// A gap: what this instance has written covers up to `openEnd`, and that is the name
// the chunk gets before a new one starts at the arriving seq.
closeOpenChunk(openEnd)
}
dir.mkdirs()
// An open chunk left by an earlier instance, or by an earlier screen.
chunks()
.lastOrNull { it.open }
?.let { existing ->
if (seq < existing.end) return null
if (seq == existing.end) {
openFile = existing.file
openEnd = existing.end
return FileWriter(existing.file, true).buffered().also { writer = it }
}
rename(existing.file, existing.first, existing.end)
}
// A chunk that was created and never written to would otherwise be left behind under a name
// a second one is about to want; it covers nothing, so nothing is lost with it.
dir.listFiles().orEmpty().forEach {
if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L)
it.delete()
}
val file = File(dir, "$seq-open.raw.jsonl")
openFile = file
openEnd = seq
return FileWriter(file, false).buffered().also { writer = it }
}
/** Renames the open chunk to the range it turned out to cover, so it stops being open. */
private fun closeOpenChunk(end: Long) {
val file = openFile
closeWriter()
if (file == null) return
val first = CHUNK_NAME.matchEntire(file.name)?.groupValues?.get(1)?.toLongOrNull()
if (first != null) rename(file, first, end)
}
private fun rename(file: File, first: Long, end: Long) {
file.renameTo(File(dir, "$first-$end.raw.jsonl"))
}
private fun closeWriter() {
try {
writer?.close()
} catch (_: IOException) {
// Nothing left to do about it: the file is what it is, and the read path repairs a
// half-written tail.
}
writer = null
openFile = null
openEnd = 0
}
// -- failure -----------------------------------------------------------------------------
/** A chunk that cannot be read as what its name claims. */
private class Damaged(val file: File) : RuntimeException()
/**
* Runs [body], answering [ifBroken] when the directory cannot give a real answer.
*
* None of this is reported on screen: none of it changes what the screen shows -- every read
* here has a network path beside it producing the same result -- and the reader has nothing to
* do about it. Damage discards this session's cache, which makes the next open an ordinary cold
* one.
*/
private fun <T> guard(ifBroken: T, body: () -> T): T =
// A disk that refused once will refuse again, once per delta, so the first refusal is also
// the last: this instance stops writing rather than logging a line a token.
guardIo(
ifBroken,
warn,
onFailure = {
disabled = true
closeWriter()
},
) {
try {
body()
} catch (e: Damaged) {
warn("transcript cache damaged at ${e.file}; discarding ${dir.name}")
closeWriter()
dir.deleteRecursively()
ifBroken
}
}
}
/** `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is not ours. */
private val CHUNK_NAME = Regex("""^(\d+)-(\d+|open)\.(rows|raw)\.jsonl$""")
private val SEQ_IN_LINE = Regex(""""seq"\s*:\s*(\d+)""")
private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""")
/**
* One line's sequence number, or null when the line is not one of ours.
*
* A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM
* tests: the seq is the first field the server writes, so the first match is the top-level one.
*/
private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull()
/** Whether a line is one streamed piece of a reply, which is what makes a run of them one row. */
private fun isDelta(line: String): Boolean =
TYPE_IN_LINE.find(line)?.groupValues?.get(1) == "assistantText"
/**
* How much of a file is read at a time when walking it backwards. One block covers a page of a
* transcript comfortably, and the walk stops as soon as the caller has what it asked for.
*/
private const val READ_BLOCK = 64 * 1024
/**
* Calls [onLine] with each non-blank line of [file], **newest first**, along with the byte offset
* it starts at, until [onLine] answers false.
*
* Every question the cache is asked is about the newest end of a chunk, and a live run reaches the
* size of the conversation, so reading forwards means reading a transcript to answer with the last
* eighty lines of it.
*
* Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte
* UTF-8 sequence; each line is decoded whole. A missing file yields nothing.
*/
private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) {
if (!file.isFile) return
RandomAccessFile(file, "r").use { handle ->
// Bytes below `unread` have not been looked at; `pending` is the oldest line so far, which
// is incomplete until a newline is found before it in an older block.
var unread = handle.length()
var pending = ByteArray(0)
while (unread > 0) {
val take = minOf(READ_BLOCK.toLong(), unread).toInt()
val start = unread - take
val block = ByteArray(take)
handle.seek(start)
handle.readFully(block)
val buffer = if (pending.isEmpty()) block else block + pending
var lineEnd = buffer.size
var at = buffer.size - 1
while (at >= 0) {
if (buffer[at] == NEWLINE) {
val line = String(buffer, at + 1, lineEnd - at - 1, Charsets.UTF_8)
if (line.isNotBlank() && !onLine(start + at + 1, line)) return
lineEnd = at
}
at--
}
pending = buffer.copyOfRange(0, lineEnd)
unread = start
}
// The first line of a file has no newline before it to be found.
val first = String(pending, Charsets.UTF_8)
if (first.isNotBlank()) onLine(0, first)
}
}
private const val NEWLINE = '\n'.code.toByte()
/**
* Drops a final line that is not one of ours, by truncating the file to where it starts.
*
* This app having died mid-write is the one kind of damage that is repaired rather than discarded:
* the tail of an append-only file is the only place a partial line can be. A second bad line is not
* this, and is left for the read path to notice.
*/
private fun repairTail(file: File) {
var truncateTo = -1L
eachLineBackwards(file) { offset, line ->
if (seqOf(line) == null) truncateTo = offset
false
}
if (truncateTo >= 0) RandomAccessFile(file, "rw").use { it.setLength(truncateTo) }
}
private fun sizeOf(file: File): Long =
if (file.isDirectory) file.listFiles().orEmpty().sumOf { sizeOf(it) } else file.length()
/**
* The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance.
* [onFailure] is what the caller does about it beyond answering [ifBroken].
*/
private fun <T> guardIo(
ifBroken: T,
warn: (String) -> Unit,
onFailure: () -> Unit = {},
body: () -> T,
): T =
try {
body()
} catch (e: IOException) {
warn("transcript cache unusable: ${e.message}")
onFailure()
ifBroken
} catch (e: SecurityException) {
warn("transcript cache unreadable: ${e.message}")
onFailure()
ifBroken
}
@@ -5,9 +5,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
* stream is the only data source -- opening a session screen replays from seq 0, and a reconnect
* resumes from the last seq seen, so there is no separate history fetch to drift from it.
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]).
*
* Events are the only data source, and there is deliberately no second shape for history to drift
* from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are
* all the same events through the same parser. [TranscriptCache] stores the server's lines rather
* than these rows for exactly that reason -- a row is a rendering, and its shape changes whenever
* this file does.
*/
@Immutable
sealed class TranscriptItem {
@@ -19,10 +23,8 @@ sealed class TranscriptItem {
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
* content underneath it slides, which reads as the view scrolling on its own.
*
* A seq is the right identity because it is what the transcript itself is ordered by, it never
* changes, and it is already carried by every event. A row built from several events -- a
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
* still while the rest of it arrives.
* A row built from several events keeps the seq of the first, so it holds still while the rest
* of it arrives.
*/
abstract val seq: Long
@@ -30,10 +32,8 @@ sealed class TranscriptItem {
* This item's identity on screen, which is its [seq] for everything that has one of its own.
*
* Here rather than in [TranscriptRow.Single] because the two items that need something else are
* the two that know why: a tool call is named after its run, and a peer note is *sorted* by the
* turn it started rather than by where it arrived. Asking each item what it is called is also
* what stops the next such item being missed -- a `when` over concrete types in the row would
* have to gain a case, silently, and nothing says when it did not.
* the two that know why. Asking each item what it is called is also what stops the next such
* item being missed -- a `when` over concrete types would have to gain a case, silently.
*/
open val key: Any
get() = seq
@@ -54,15 +54,13 @@ sealed class TranscriptItem {
* What it buys is the split. [transcriptUnits] keeps the newest reply whole because a
* streaming reply's text changes per delta and splitting a changing text is a parse per
* delta -- but "newest" outlives the turn, so a session that ends on a long reply was
* drawing it as one item indefinitely, with every node of it alive. Measured on a Pixel 9
* Pro XL: one 34,996px reply on screen put the frame's draw phase at 13.8ms, 79% of it the
* framework's own bookkeeping, which grows with alive nodes.
* drawing it as one item indefinitely. Measured on a Pixel 9 Pro XL: one 34,996px reply on
* screen put the frame's draw phase at 13.8ms, 79% of it framework bookkeeping.
*
* Folded from the status event that ended the turn, rather than read off the screen's
* Folded from the status event that ended the turn rather than read off the screen's
* status, because rows only change through the held-events gate: the split changes the
* newest row's list identity, and doing that from a status flip while somebody is reading
* inside that reply would step the list under them. An event has to wait for the reader to
* be at the newest end; a screen state does not.
* inside that reply would step the list under them.
*/
val settled: Boolean = false,
) : TranscriptItem()
@@ -74,11 +72,10 @@ sealed class TranscriptItem {
* The run of adjacent calls this one belongs to, named once when the call is folded in and
* never recomputed.
*
* Carried rather than derived because a run can gain members at *either* end -- a new call
* arriving beside it, or a page of history arriving in front of it -- so no function of its
* current members is stable. It is the first call's id at the moment the run started, which
* is a name rather than a description: [joinPages] hands it to older calls that turn out to
* belong to the same run, instead of renaming the run they joined.
* Carried rather than derived because a run can gain members at *either* end, so no
* function of its current members is stable. It is the first call's id at the moment the
* run started, which is a name rather than a description: [joinPages] hands it to older
* calls that turn out to belong to the same run.
*/
val runId: String,
val tool: String,
@@ -89,19 +86,16 @@ sealed class TranscriptItem {
* The questions this call is waiting on, in the order they were asked.
*
* On the call's own row rather than beside it: an ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice and had to work
* out that it was one event. The backend says which call a question is about, so this is a
* fact rather than a match on the input.
* repeating the input verbatim, so the reader saw the same command twice. The backend says
* which call a question is about, so this is a fact rather than a match on the input.
*
* A list because AskUserQuestion asks up to four at once, and they are one decision to make
* -- a permission is the case of exactly one, not a different shape.
* A list because AskUserQuestion asks up to four at once, and a permission is the case of
* exactly one rather than a different shape.
*/
val asks: List<QuestionCard> = emptyList(),
/**
* Images this call's result carried, drawn under it.
*
* Beside it they had to be paired by position, and position is the thing a page boundary
* breaks -- a screenshot loaded on one page and its call on the next read as unrelated.
* Images this call's result carried, drawn under it. Beside it they had to be paired by
* position, and position is what a page boundary breaks.
*/
val images: List<String> = emptyList(),
) : TranscriptItem() {
@@ -129,9 +123,8 @@ sealed class TranscriptItem {
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
/**
* A message another agent sent this session.
*
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
* A message another agent sent this session. Its own row rather than a [UserMsg]: see
* [PeerMessageRow] for why the voice matters.
*/
data class PeerNote(
override val seq: Long,
@@ -140,11 +133,9 @@ sealed class TranscriptItem {
/**
* The seq of the event this note came in on, which is what makes it itself.
*
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began at
* so the note is drawn above the reply it caused. Two messages that arrive during one turn
* therefore share a seq -- and sharing an identity as well killed the app, because the
* transcript list refuses two items with one key. Two agents writing to a session mid-turn
* is an ordinary afternoon, not a corner.
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began
* at. Two messages that arrive during one turn therefore share a seq -- and sharing an
* identity as well killed the app, because the list refuses two items with one key.
*/
val arrived: Long = seq,
) : TranscriptItem() {
@@ -153,10 +144,9 @@ sealed class TranscriptItem {
}
/**
* A command the session ran on itself -- `/compact`, `/rename`.
*
* Kept in the transcript rather than only shown while it waits, because it explains what
* follows: a conversation that suddenly has half the context, or a session with a new name.
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
* than only shown while it waits, because it explains what follows: a conversation that
* suddenly has half the context, or a session with a new name.
*/
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
@@ -165,7 +155,6 @@ sealed class TranscriptItem {
/**
* A clear that happened: everything above it left the session's context and stayed on screen.
*
* Carries only its position, because that is all it means.
*/
data class ClearedNote(override val seq: Long) : TranscriptItem()
@@ -174,34 +163,43 @@ sealed class TranscriptItem {
* A compaction that happened, and what it recovered.
*
* In the transcript rather than only in the status line, because the status is gone the moment
* it finishes and this is the part worth keeping: it is the explanation for a gap in the
* conversation, and for a minute or two in which the session was busy with nothing to show.
* it finishes and this is the part worth keeping: the explanation for a gap in the
* conversation.
*
* The wire also says what triggered it, and this deliberately does not carry that: the row says
* the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would
* be a field nothing can read.
* The wire also says what triggered it, and this deliberately does not carry that -- the row
* says the two sizes and nothing else, so keeping the trigger would be a field nothing can
* read.
*/
data class CompactedNote(
override val seq: Long,
val preTokens: Long?,
val postTokens: Long?,
) : TranscriptItem()
/**
* The account ran out of quota, so the turn stopped here.
*
* A divider rather than an error: nothing failed, and what a reader scrolling back needs from
* it is the same thing a clear or a compaction gives them -- why the conversation stops at this
* line.
*
* [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the
* row has words for rather than a time it invents.
*/
data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem()
}
/**
* The run a call joins: the one it lands next to, or a new one named after itself.
*
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
* keeps whatever it was called when it started, however many calls arrive at either end of it
* afterwards.
* keeps whatever it was called when it started, however many calls arrive at either end afterwards.
*
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row
* rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is
* always visible, since a run of one is drawn as itself rather than as a group; and the calls
* around it fall into a group before it and a group after it, so where the reader was asked
* something is legible in the shape of the transcript without opening anything. It ends the run
* before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in
* the work, not a gap in the middle of one run.
* rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible,
* since a run of one is drawn as itself; and the calls around it fall into a group before it and a
* group after it, so where the reader was asked something is legible in the shape of the transcript
* without opening anything.
*/
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
@@ -214,31 +212,23 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
* boundary cut in two.
*
* Two things straddle a boundary: a tool call separated from its result, and a message separated
* from the rest of itself. Both were one thing before the transcript was cut into pages, and both
* have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn
* twice, arriving from the same cause.
* from the rest of itself. Both were one thing before the transcript was cut into pages.
*
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a
* nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than
* the miscount -- the extra row is at the join, so it also moves everything the reader was looking
* at.
* concatenating the two lists left *both*: the same call twice.
*
* Merged by the call's own id rather than by position, because position is exactly what a page
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
* newer on what an end knows (the output, and whether it finished), which is the only way round
* that loses nothing.
* boundary destroys. The older row wins on what a start knows and the newer on what an end knows,
* which is the only way round that loses nothing.
*
* The third thing is the *run*, and it is the one that used to be missed. Every page ends up here,
* but [adoptRun] only ran on the path where a split call had been found -- so the boundary that
* falls cleanly between two finished calls, which is most of them, went straight to concatenation
* and left the older page's calls under the run name they were folded with. On screen: one run of
* tool calls drawn as two groups, with the seam wherever the reader happened to have paged. The two
* early returns were an optimisation on a list the size of one page, and they were skipping work
* rather than saving it.
* falls cleanly between two finished calls, which is most of them, left the older page's calls
* under the run name they were folded with. On screen: one run of tool calls drawn as two groups,
* with the seam wherever the reader happened to have paged.
*/
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later)
@@ -271,15 +261,13 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
/**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
*
* [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas
* accumulate into the message before them -- so two meeting at a join are always the two halves of
* one reply, and leaving them apart drew a single answer as two, with a paragraph break through the
* middle of a sentence.
* [foldEvent] never leaves two assistant messages next to each other inside one page, so two
* meeting at a join are always the two halves of one reply, and leaving them apart drew a single
* answer as two with a paragraph break through the middle of a sentence.
*
* The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on
* screen, and renaming that is how the list loses its anchor. It grows by what the older half
* brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded,
* so the growth extends off the top of the screen, away from the row the list anchors to.
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
* loaded, so the growth extends off the top of the screen.
*/
private fun healSplitMessage(
earlier: List<TranscriptItem>,
@@ -297,19 +285,18 @@ private fun healSplitMessage(
* Hands the older calls at the join the name of the run they are joining.
*
* The two pages were folded separately, so a run split by the boundary came back as two runs with
* two names. Naming the joined run after the *older* half would be the obvious way round and is the
* wrong one: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor and steps under them. So the arriving
* calls take the name of the ones already there, and nothing visible changes identity.
* two names. Naming the joined run after the *older* half would be the obvious way round and is
* wrong: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor.
*/
private fun adoptRun(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): List<TranscriptItem> {
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
// A question is in a run of its own on both sides of the join, the same as it would be had
// the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a
// group straight through the row the reader was asked something on.
// A question is in a run of its own on both sides of the join, the same as it would be had the
// two pages been folded as one. Without this the heal would merge a group straight through the
// row the reader was asked something on.
if (first.tool == ASK_USER_QUESTION) return earlier
val joining = first.runId
val tail = earlier.takeLastWhile {
@@ -324,10 +311,8 @@ private fun adoptRun(
* A peer message goes above the turn it started, not where it happened to arrive.
*
* The live Claude Code path cannot record it in place: the CLI says nothing about a peer message
* until the turn's `result`, so the event lands below the whole reply it caused -- the answer
* printed above the question. The server stamps it with where that turn began
* ([SessionEvent.PeerMessage.turnStart]) and the note takes that seq, so it sorts into the list
* where it belongs rather than being drawn out of order at the end.
* until the turn's `result`, so the event lands below the whole reply it caused. The server stamps
* it with where that turn began and the note takes that seq.
*
* Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and
* paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as
@@ -335,8 +320,7 @@ private fun adoptRun(
* seq belongs to a status change and a status draws no row -- true, and it answered the wrong
* question: what two notes stamped with the same turn collide with is each other.
*
* Without a stamp -- a message replayed out of a session file, which is already in the right place
* -- it stays where it arrived.
* Without a stamp -- a message replayed out of a session file -- it stays where it arrived.
*/
private fun placePeerNote(
items: List<TranscriptItem>,
@@ -355,15 +339,14 @@ private fun placePeerNote(
* The calls the note now sits in front of, renamed if they were sharing a run with the calls behind
* it.
*
* A run is named from what a call landed next to (see [runIdFor]), and nothing there knows about
* turns -- so a turn opening with a tool call, straight after one that ended with one, folds them
* into a single run. Left alone, [groupToolRuns] would flush at the note and hand both halves the
* same name: two rows with one key, which a keyed list cannot draw at all.
* A run is named from what a call landed next to, and nothing there knows about turns -- so a turn
* opening with a tool call, straight after one that ended with one, folds them into a single run.
* Left alone, [groupToolRuns] would flush at the note and hand both halves the same name: two rows
* with one key, which a keyed list cannot draw at all.
*
* The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right
* for the opposite reason. There the two halves were always one run and the newer was already on
* screen; here they were never one turn's work, and both halves change appearance at the same
* moment the note appears between them.
* for the opposite reason: there the two halves were always one run, here they were never one
* turn's work.
*/
private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<TranscriptItem> {
val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail
@@ -401,12 +384,10 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
)
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A
// fold that only ever *updates* loses the whole call when the
// start fell outside the loaded window, and a tool call that
// renders as nothing is indistinguishable from one that never
// happened. The name is unknown from an end alone; loading the
// page before this one replaces the row with the real thing.
// Created when its start is not here, rather than dropped. A fold that only ever
// *updates* loses the whole call when the start fell outside the loaded window, and a
// tool call that renders as nothing is indistinguishable from one that never happened.
// Loading the page before this one replaces the row with the real thing.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
} else {
@@ -414,9 +395,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
TranscriptItem.ToolRun(
entry.seq,
event.id,
// The name is not known from an end alone, so a call that was an ask
// cannot be recognised as one here; loading the page before this
// replaces the row with the real thing, which is when it splits out.
// The name is not known from an end alone, so a call that was an ask cannot
// be recognised as one here; the page before this replaces the row.
runIdFor(items, event.id, "tool"),
"tool",
"",
@@ -435,9 +415,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
event.multiSelect,
emptyList(),
)
// A question with no tool behind it -- AskUserQuestion, or an ask
// whose call fell outside the loaded window -- is a card of its
// own, which is what every question was before this.
// A question with no tool behind it -- AskUserQuestion, or an ask whose call fell
// outside the loaded window -- is a card of its own.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
@@ -448,9 +427,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
}
}
is SessionEvent.Answered ->
// Resolved wherever it is drawn: a card of its own, or a tool
// row's ask. Missing the second left an Allow/Deny pair live on
// a question already answered from another device.
// Resolved wherever it is drawn: a card of its own, or a tool row's ask. Missing the
// second left an Allow/Deny pair live on a question already answered from another
// device.
items.map {
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
@@ -470,20 +449,19 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.CommandQueued -> items
// No row of its own: a message that is still waiting is drawn as a pending bubble below
// the transcript, and becomes an ordinary one where the session read it.
// No row of its own: a message that is still waiting is drawn as a pending bubble below the
// transcript, and becomes an ordinary one where the session read it.
is SessionEvent.MessageQueued -> items
// The bubble goes away and nothing takes its place: the message was never read, so there
// is nothing it belongs above.
// The bubble goes away and nothing takes its place: the message was never read, so there is
// nothing it belongs above.
is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items
is SessionEvent.Status -> settleReply(items, event.state)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of
// its own when there is not -- a person's own attachment belongs
// to no call, and neither does one whose call fell outside the
// loaded window.
// Under the call that produced it when there is one, and a row of its own when there is
// not -- a person's own attachment belongs to no call, and neither does one whose call
// fell outside the loaded window.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
@@ -492,6 +470,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
} else {
items + TranscriptItem.ImageItem(entry.seq, event.ref)
}
is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt)
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
@@ -503,9 +482,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
/**
* A status saying the session stopped working is the moment its newest reply is finished.
*
* See [TranscriptItem.AssistantMsg.settled] for what the mark buys and why it is made here in the
* fold. Status changes are transcript events with seqs of their own, so a replayed session settles
* its replies the same way a live one does.
* See [TranscriptItem.AssistantMsg.settled]. Status changes are transcript events with seqs of
* their own, so a replayed session settles its replies the same way a live one does.
*/
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
if (sessionWorking(state)) return items
@@ -528,8 +506,7 @@ private fun updateTool(
* The default dispatcher sizes itself to the machine, which is right for work somebody is waiting
* on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and
* taking every core for them leaves the thread that draws the frame queueing behind one -- measured
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile, which is the frame failing to
* *start* rather than taking too long once it had.
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile.
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
@@ -538,37 +515,33 @@ private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
* Parses the markdown among [rows], off whatever thread is drawing.
*
* Called where a page of transcript is folded rather than where a row is composed, which is the
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
* [ParsedReplies].
* whole point: the work happens seconds before the reader reaches the rows it was done for.
*
* What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
* message, every one of them whole, since every piece of a message is drawn from its one parse --
* because a string warmed under a key no row ever looks up is a miss that nothing reports; see
* [transcriptUnits], which is the flatten this has to agree with. It reads the same
* message -- because a string warmed under a key no row ever looks up is a miss that nothing
* reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same
* [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
* hand it back through here, while the whole loaded transcript crosses this on every page.
* hand it back through here.
*
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
* had thought about paid its whole parse in the frame it appeared in, with no counter saying which
* row it was.
* had thought about paid its whole parse in the frame it appeared in.
*/
suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
withContext(parsingThreads) {
val texts = rows.flatMap { row ->
when (row) {
is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
// A message from another agent is markdown too, and it is the longest thing
// in a transcript often enough that leaving it out was the whole of why one
// cost a fifth of a second to open: it was the only markdown in the app
// parsed on the thread that draws.
// A message from another agent is markdown too, and it is the longest thing in a
// transcript often enough that leaving it out was the whole of why one cost a fifth
// of a second to open.
is TranscriptItem.PeerNote -> listOf(row.text)
else -> emptyList()
}
}
if (texts.isNotEmpty()) replies.warm(texts)
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's
// licence to draw these as blocks on the composing thread.
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's licence
// to draw these as blocks on the composing thread.
rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) }
}
}
@@ -25,31 +25,24 @@ import androidx.compose.ui.unit.dp
* zero is the newest content and sits at the bottom, so a message arriving extends the end the
* viewport is pinned to and following it is not an effect -- and a page of older history lands at
* indices past everything visible, which moves nothing on screen. The keyboard is the same case
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge. A
* conversation shorter than the screen stacks from the bottom, hanging from the composer.
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge.
*
* The lazy list is also the whole of the windowing. Only what is near the viewport is composed and
* alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the
* property a plain column here had to approximate with retained ranges and stand-in spacers, each
* of which was a way to flicker. An item the framework composes is drawn the same frame it is
* placed, and an item off screen is not a node at all.
* of which was a way to flicker.
*
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
* reply, and its parse is already made by [warm] before the fold that introduces it -- so entering
* composition costs laying out one paragraph, not parsing a message.
* reply, and its parse is already made by [warm] before the fold that introduces it.
*
* The whole list sits in a [SelectionContainer], which is what makes every word in the transcript
* selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a
* transcript is one body of text to a reader, and a container per row would mean a selection could
* never cross from a reply into the tool output that follows it -- and would leave whatever was
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
* their tap handlers: selection is a long press, and the container passes an ordinary click through
* to the card under it.
* The whole list sits in a [SelectionContainer], which is what makes every word selectable by the
* platform's own press-and-hold. Here rather than at each place text is drawn: a transcript is one
* body of text to a reader, and a container per row would mean a selection could never cross from a
* reply into the tool output that follows it -- and would leave whatever was drawn without one
* silently unselectable. Rows keep their tap handlers: selection is a long press.
*
* [selection] is the container's own state, held by the caller rather than made here, because the
* rows have to be able to ask whether anything is selected before they act on a tap -- a tap whose
* job is to put a selection away is not also a tap on the card under it. See the caller's
* `expanding`.
* rows have to be able to ask whether anything is selected before they act on a tap.
*/
@Composable
fun TranscriptList(
@@ -69,8 +62,7 @@ fun TranscriptList(
modifier =
// Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which
// half;
// see [drawAccounting]. Measure includes composing the items that scrolled in.
// half. Measure includes composing the items that scrolled in.
modifier
.layout { measurable, constraints ->
val started = System.nanoTime()
@@ -104,8 +96,7 @@ fun TranscriptList(
}
// Standing in for everything not fetched yet. Only here while there is more -- its
// appearance at the top edge is also roughly when the next page is asked for, so what
// it
// reports is a fetch in flight rather than an end reached.
// it reports is a fetch in flight rather than an end reached.
if (moreHistory) {
item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
@@ -0,0 +1,177 @@
package com.example.aiapp
import android.content.Context
import java.io.File
import java.util.concurrent.atomic.AtomicReference
/**
* Where the session screen gets a transcript from: this phone's copy first, the server for the
* rest.
*
* One seam rather than a cache the screen has to remember to consult. Everything it fetched before
* is asked of this, and everything the server sends is written into the cache on the way past, so
* the screen never learns which side answered. What it does learn, through [DebugStats], is how
* often each one did.
*
* See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind: the cache is never load-bearing.
* Every read has a network path beside it producing the same result.
*/
class TranscriptSource(
private val settings: ServerSettings,
private val address: TranscriptAddress,
val cache: SessionCache,
) {
private val stream = AtomicReference<EventStream?>(null)
/**
* The cached opening window, or null when there is nothing usable to draw.
*
* Drawn *before* [probe] returns, which is the whole point of the feature: the rows are on
* screen while the check that they are still the server's rows is in flight, and a failed check
* replaces them exactly as a `reset` does.
*/
fun cachedOpening(limit: Int = OPENING_WINDOW): List<SeqEvent>? {
if (cache.tail() == null) return null
val lines = cache.newest(limit)
if (lines.isEmpty()) return null
return try {
lines.map { parseSeqEvent(it) }
} catch (e: org.json.JSONException) {
// Lines this build cannot read at all, which the cache's own checks cannot see: it
// reads a seq off a line, not an event. Nothing to serve, so a cold open.
cache.purge()
null
}
}
/**
* Whether the server's event at the cached cursor is still the cached one.
*
* The screen must not resume a stream from a cached seq unless it is the same conversation. A
* transcript is append-only in ordinary use, but the file can be replaced or truncated -- a
* sandbox re-seeded with the same ids, a backup restored, a session re-imported -- and the
* server's catch-up on such a file would hand this phone a continuation of a *different*
* conversation, spliced onto the cached one with no seam. Caught with one request of a few
* hundred bytes, in the slot the opening page's request used to be in.
*
* False purges the cache and means "open cold". A throw is the server not being askable, which
* is neither: the cached rows stay on screen and the caller tries again on the reconnect
* schedule.
*
* What this cannot see is a line changed in the middle of the file with the tail intact. That
* is what the Reload button in session settings is for.
*/
suspend fun probe(): Boolean {
val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there.
val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
val matches =
answer.size == 1 &&
try {
answer[0].second == parseSeqEvent(tail.line)
} catch (e: org.json.JSONException) {
false
}
if (!matches) cache.purge()
return matches
}
/**
* Today's opening fetch, kept as the start of the live run. Only called when the cache has
* nothing to open with, or when [probe] said what it had was not the server's.
*/
suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush()
return page.map { it.second }
}
/**
* The page before [before]: from the cache when it holds it, otherwise from the server bounded
* by what the cache already has.
*
* The bound is what keeps the cache worth having. A coalesced page reaches back as far as its
* row count takes it -- a single reply is hundreds of lines -- so a page fetched after the
* reader has been away would run straight past the cached run and overlap it, and an
* overlapping page cannot be stored. Told where this phone's copy starts, the server stops
* there instead.
*/
suspend fun page(before: Long, limit: Int, coalesce: Boolean): List<SeqEvent> {
cache.page(before, limit, rows = coalesce)?.let { lines ->
DebugStats.count("transcript page from cache")
return lines.map { parseSeqEvent(it) }
}
DebugStats.count("transcript page from server")
val page =
fetchTranscript(
settings,
address,
before = before,
limit = limit,
coalesce = coalesce,
after = cache.coveredUpTo(before)?.minus(1),
)
if (page.isNotEmpty()) {
// `before` rather than the newest line's seq: a coalesced page covers everything up to
// the cursor it was asked with, and nothing in its lines says so.
cache.storePage(page.map { it.first }, page.first().second.seq, before, rows = coalesce)
}
return page.map { it.second }
}
/**
* [EventStream.run], with every frame written to the cache before [onEvent] sees it.
*
* Before, so that an event held back for a reader who is scrolled away is already on disk --
* what the cache holds is what the server sent, not what the screen has got round to drawing.
* Flushed on each status change, which is a turn's boundary and the granularity a crash may as
* well lose.
*/
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, address)
stream.getAndSet(opened)?.close()
try {
opened.run(after, onOpen, onReset) { raw, entry ->
cache.append(raw, entry.seq)
if (entry.event is SessionEvent.Status) cache.flush()
onEvent(entry)
}
} finally {
cache.flush()
}
}
/** Ends the stream, from any thread, and leaves the cache with everything it was given. */
fun close() {
stream.getAndSet(null)?.close()
cache.flush()
}
}
/**
* How many events the screen opens with, cached or fetched.
*
* The server's own default for a page, named here because the cached opening has to be the same
* size as the fetched one -- a reader must not get a shorter first screen for having been here
* before.
*/
private const val OPENING_WINDOW = 80
/**
* Where this server's cached transcripts live.
*
* Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the
* server, which Android may delete under storage pressure without asking. Keyed by host and port
* because two servers can hold a session with the same id, and a line from one shown against the
* other is the whole invariant broken. `v1` is the layout's version.
*/
fun cacheRoot(context: Context, settings: ServerSettings): File {
val transcripts = File(context.cacheDir, "transcripts")
transcripts.listFiles()?.forEach { if (it.name != CACHE_VERSION) it.deleteRecursively() }
return File(transcripts, "$CACHE_VERSION/${settings.host}_${settings.port}")
}
private const val CACHE_VERSION = "v1"
@@ -12,8 +12,7 @@ import androidx.compose.ui.unit.dp
* at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be
* twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when
* the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst
* frame is bounded. This is the piece that was missing when a lazy list was last tried here; the
* block splitting existed only inside the row, where the list could not see it.
* frame is bounded. This is the piece that was missing when a lazy list was last tried here.
*
* Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit
* points back at its row. The list draws units; anchors and paging still speak seq.
@@ -27,10 +26,9 @@ sealed class TranscriptUnit {
abstract val seq: Long
/**
* This unit's position within its row, counted from the row's oldest end.
*
* What a saved scroll position carries besides the seq: a reply split into forty blocks needs
* more than "somewhere in this row" to put a reader back where they stopped.
* This unit's position within its row, counted from the row's oldest end. What a saved scroll
* position carries besides the seq: a reply split into forty blocks needs more than "somewhere
* in this row" to put a reader back where they stopped.
*/
abstract val ordinal: Int
@@ -66,15 +64,13 @@ sealed class TranscriptUnit {
*
* A peer message is the one row whose *opened* size is unbounded -- these are the longest
* things a transcript holds -- so it is flattened the same way a settled reply is, and for the
* same reason: as one item, every block of it is composed, measured, placed and kept alive
* while any part of it is on screen. Measured on the emulator, opening a 43KB one took the
* transcript's share of the draw phase from 0.81ms a frame to 3.85ms, and the framework's own
* per-frame bookkeeping -- which grows with how many nodes are *alive* -- from 0.39ms to
* 3.15ms.
* same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the
* draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from
* 0.39ms to 3.15ms.
*
* The card is drawn in pieces rather than given up: a filled Material card is elevation zero,
* so it has no shadow to break, and each piece paints the same fill with only the corners it
* owns. See [PeerHeadRow] and [PeerBlockRow].
* owns.
*/
data class PeerHead(
override val seq: Long,
@@ -84,8 +80,7 @@ sealed class TranscriptUnit {
) : TranscriptUnit() {
/**
* The note's own key, so opening and shutting does not change what the list is anchored on
* -- and so two notes stamped with one turn's seq are still two items. See
* [TranscriptItem.PeerNote].
* -- and so two notes stamped with one turn's seq are still two items.
*/
override val key: Any
get() = item.key
@@ -119,8 +114,7 @@ sealed class TranscriptUnit {
*
* A user message is plain text, so cutting it costs a scan rather than a parse -- but the
* reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand
* pixels of `Text` whose layout lands in the frame the row scrolls into. Measured as the
* `measure: the whole transcript ... 112.1ms worst` in an otherwise smooth report.
* pixels of `Text` whose layout lands in the frame the row scrolls into.
*/
data class UserChunk(
override val seq: Long,
@@ -152,14 +146,11 @@ sealed class TranscriptUnit {
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start.
*
* Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is
* only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are,
* which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting.
* The reply still arriving -- the newest row, until the status event that ends its turn marks it
* [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and
* splitting it here would parse the whole message per delta on whichever thread is composing.
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
* live message a layer per piece. Once settled it splits like every other reply, which is what
* Every settled reply is cut into its pieces (via the caches on [replies] so a message is only ever
* cut once), and so is an *opened* peer message -- [openNotes] is which ones those are. A shut one
* is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text
* changes with every delta, and splitting it here would parse the whole message per delta on
* whichever thread is composing. Once settled it splits like every other reply, which is what
* bounds the newest row's cost after a session ends on a long one.
*
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
@@ -200,8 +191,8 @@ fun transcriptUnits(
}
}
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like
// the markdown splits so the scan too happens once per message, not once per fold.
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like the
// markdown splits so the scan happens once per message rather than once per fold.
val chunks = replies.chunksOf(item.text)
chunks.forEachIndexed { at, chunk ->
units +=
@@ -252,8 +243,8 @@ fun transcriptUnits(
}
units.reverse()
reportDuplicateKeys(units)
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy"
// is this number growing, and it was invisible until it was written down.
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is
// this number growing, and it was invisible until it was written down.
DebugStats.record("units flattened", System.nanoTime() - started)
return units
}
@@ -261,9 +252,8 @@ fun transcriptUnits(
/**
* Whether this reply should be drawn as blocks: settled, or anywhere but the newest row.
*
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two
* questions are separate because they are answered by different things: this one by the fold, the
* other by whether [warm] has run for the text. [unwarmedReplies] is the gap between them.
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are
* answered by different things: this one by the fold, the other by whether [warm] has run.
*/
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
item.settled || index != lastIndex
@@ -272,8 +262,7 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex
* The replies among [rows] that should draw as blocks but whose parses are not made yet.
*
* Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold
* is the reply that just finished streaming -- nothing warms live deltas, so at the moment its turn
* ends its split would cost a whole-message parse on the composing thread. The session screen warms
* is the reply that just finished streaming -- nothing warms live deltas. The session screen warms
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses.
*/
@@ -12,19 +12,15 @@ import androidx.compose.runtime.Composable
* on the main thread -- so it is not an error the screen can show, it closes the app. That is a
* disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on
* whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id
* recorded under two project directories, which is an ordinary state of a machine and not something
* the phone did.
* recorded under two project directories, which is an ordinary state of a machine.
*
* Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared
* the hazard and none of them could rule it out locally. Hence one function they all go through
* rather than a `distinctBy` remembered at each call site.
* the hazard and none could rule it out locally. Hence one function they all go through.
*
* Dropping the repeat is the right answer here because the key is the whole identity: two rows with
* one id are two rows every action would treat as the same thing, so there is nothing to show about
* the second that the first is not already showing. Where the duplicate means something -- the
* import list's did -- the fix belongs at the source, and this is only what stops a data problem
* from being a crash. It is counted so the render report says it happened rather than leaving a
* silently shorter list.
* Dropping the repeat is right here because the key is the whole identity: two rows with one id are
* two rows every action would treat as the same thing. Where the duplicate means something, the fix
* belongs at the source, and this is only what stops a data problem from being a crash. It is
* counted so the render report says it happened rather than leaving a silently shorter list.
*
* The transcript's own list is deliberately not on this: its keys are made here rather than
* received, and it is the one list where an extra pass over the items is measurable.
@@ -27,17 +27,14 @@ import java.time.OffsetDateTime
* A dialog rather than a screen. Usage is something you check *against* what you were reading --
* "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for
* it took the session away to answer a question about the session. It also has no navigation of its
* own: there is nothing here to open, so the only thing its Back could ever have meant was "put
* this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog`
* handles that itself.
* own, so the only thing its Back could ever have meant was "put this away".
*/
@Composable
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the
// gaps between its title, its content and its buttons at sizes meant for a sentence of prose
// and a decision; this is a dense read-out, and those gaps left a band of empty dialog above
// Close that was taller than a bar. Everything else here is what AlertDialog would have
// drawn -- the same container colour, the same corner -- so nothing about it looks foreign.
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
// taller than a bar.
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
@@ -49,11 +46,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
modifier = Modifier.fillMaxWidth(),
) {
// Deliberately not subtitled with the provider this was opened from. These
// numbers belong to an account on a particular machine, reported by whichever
// paid service answered there -- naming the session's provider here made an
// echo session's screen read "echo" above a line reading "claude", which is a
// claim about echo that nothing measured. Each machine names itself and the
// service it came from, which is the true scope.
// numbers belong to an account on a particular machine -- naming the session's
// provider here made an echo session's screen read "echo" above a line reading
// "claude". Each machine names itself and the service it came from.
Text(
"Usage",
style = MaterialTheme.typography.headlineSmall,
@@ -69,10 +64,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
}
}
Spacer(Modifier.height(8.dp))
// Scrolls rather than being trimmed: a machine can report any number of windows
// and there can be any number of machines, and a dialog is the one place where
// running out of room is silent. `fill = false` so a short read-out keeps a short
// dialog instead of stretching to the window.
// Scrolls rather than being trimmed: a machine can report any number of windows and
// there can be any number of machines, and a dialog is the one place where running
// out of room is silent. `fill = false` so a short read-out keeps a short dialog.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
UsageBody(feed.snapshots)
}
@@ -93,8 +87,8 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
if (current.value.isEmpty()) {
// Not an error and not a blank screen: no machine offers a paid service,
// so there is genuinely nothing to report and saying so is the answer.
// Not an error and not a blank screen: no machine offers a paid service, so
// there is genuinely nothing to report and saying so is the answer.
Text(
"No machine here runs anything with usage limits.",
style = MaterialTheme.typography.bodyMedium,
@@ -103,17 +97,16 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
} else {
// No card around each machine. A card is a step up the surface ladder, and
// inside a dialog -- itself a raised surface -- the step barely renders while
// costing 16dp of padding on every side. What separates one machine from the
// next is the line naming it, which is enough for a list this short.
// costing 16dp on every side. What separates one machine from the next is the
// line naming it.
current.value.forEachIndexed { index, snapshot ->
if (index > 0) {
Spacer(Modifier.height(20.dp))
}
// Machine and service on one line: which account these numbers belong to
// is decided by both together, and stacked as a heading over a subtitle
// they read as a section of their own rather than as the label they are.
// Small and quiet, because the numbers below are what somebody opened
// this to see.
// Machine and service on one line: which account these numbers belong to is
// decided by both together, and stacked as a heading over a subtitle they
// read as a section of their own. Small and quiet, because the numbers
// below are what somebody opened this to see.
Text(
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
style = MaterialTheme.typography.bodySmall,
@@ -121,8 +114,8 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
)
SnapshotState(snapshot)
snapshot.windows.forEachIndexed { windowIndex, window ->
// Between the bars, not after the last one: a trailing gap here is
// what put a band of empty dialog above the Close button.
// Between the bars, not after the last one: a trailing gap here is what
// put a band of empty dialog above the Close button.
if (windowIndex > 0) {
Spacer(Modifier.height(12.dp))
}
@@ -139,8 +132,7 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
*
* The distinction the old single message could not draw. A machine nobody has logged in on is
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
* the interface nagging about a decision already made, and would dilute the marks that do mean
* something. Only the two faults are coloured as faults.
* the interface nagging about a decision already made. Only the two faults are coloured as faults.
*/
@Composable
private fun SnapshotState(snapshot: UsageSnapshot) {
@@ -152,8 +144,8 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Reached but refused, versus never reached at all: different things to go and do,
// so they say different things rather than sharing one "unavailable".
// Reached but refused, versus never reached at all: different things to go and do, so they
// say different things rather than sharing one "unavailable".
"failed" ->
Text(
snapshot.detail ?: "Couldn't read the limits from this machine.",
@@ -200,10 +192,9 @@ private fun WindowBar(window: UsageWindow) {
/**
* "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing.
*
* Null for a window that is not running, which is the case this row has always drawn as nothing and
* is right to: there is no end to report. What it used to get wrong is the other missing case, a
* timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared
* as an ISO string in the middle of a sentence written for a person. Both cases are named in
* Null for a window that is not running: there is no end to report. What this used to get wrong is
* the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse
* failure appeared as an ISO string in a sentence written for a person. Both are named in
* [WindowEnd], and the session bar words them the same way.
*/
private fun resetLine(window: UsageWindow): String? =
Binary file not shown.
@@ -0,0 +1,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>
@@ -0,0 +1,87 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The line arithmetic behind the file viewer.
*
* Worth a test rather than an eye: a line number that is one out is invisible in a short file and
* obvious in a long one, and a colour that stops at a line break is invisible until the file has a
* block comment in it.
*/
class FileLinesTest {
@Test
fun `a file that ends with a newline has the number of lines its author would count`() {
assertEquals(listOf("one", "two"), FileLines.of("one\ntwo\n", null).lines)
assertEquals(listOf("one", "two"), FileLines.of("one\ntwo", null).lines)
// Only one is dropped: a blank line at the end of a file is a line somebody typed.
assertEquals(listOf("one", "two", ""), FileLines.of("one\ntwo\n\n", null).lines)
}
@Test
fun `an empty file is one empty line`() {
val lines = FileLines.of("", null)
assertEquals(1, lines.size)
assertEquals("", lines.line(0).text)
}
@Test
fun `a comment that spans lines is coloured on every line it covers`() {
val text = "fn a() {}\n/* still\n a comment */\nfn b() {}\n"
val lines = FileLines.of(text, Language.RUST)
assertEquals(4, lines.size)
val comment = catppuccinSyntax().of(Kind.COMMENT)
// The whole of the middle line, and the part of the third up to the closer.
assertTrue(lines.line(1).spanStyles.any { it.item.color == comment && it.start == 0 })
val third = lines.line(2)
assertTrue(third.spanStyles.any { it.item.color == comment && it.end == third.length })
// And the code around it is not commented.
assertTrue(lines.line(0).spanStyles.none { it.item.color == comment })
assertTrue(lines.line(3).spanStyles.none { it.item.color == comment })
}
@Test
fun `a span never runs past the line it was cut into`() {
val lines = FileLines.of("val x = \"a\nb\"\nval y = 1\n", Language.KOTLIN)
for (index in 0 until lines.size) {
val line = lines.line(index)
assertTrue(
line.spanStyles.all { it.start >= 0 && it.end <= line.length },
"line $index",
)
}
}
/**
* The number every row in the viewer is sized to. It has to be the widest line, because rows of
* their natural widths scroll sideways by different amounts -- see [FileViewer].
*/
@Test
fun `the column count is the widest line, counting a tab as eight`() {
assertEquals(5, FileLines.of("one\nthree\nx\n", null).columns)
// A tab counts up to eight, and upwards on purpose: over-estimating leaves empty space
// past the longest line, under-estimating puts its end out of reach.
assertEquals(9, FileLines.of("\tx\nshort\n", null).columns)
// An empty file is one empty line, which is no columns at all rather than an error.
assertEquals(0, FileLines.of("", null).columns)
}
@Test
fun `a file with no language is plain`() {
val lines = FileLines.of("fn main() {}\n", null)
assertTrue(lines.line(0).spanStyles.isEmpty())
}
@Test
fun `a language comes from the extension, and only from a real one`() {
assertEquals(Language.KOTLIN, fileLanguage("Main.kt"))
assertEquals(Language.KOTLIN, fileLanguage("build.gradle.kts"))
assertEquals(Language.RUST, fileLanguage("files.rs"))
assertEquals(Language.TOML, fileLanguage("Cargo.toml"))
assertEquals(null, fileLanguage("Makefile"))
assertEquals(null, fileLanguage(".bashrc"))
assertEquals(null, fileLanguage("notes.txt"))
}
}
@@ -16,9 +16,7 @@ import kotlin.test.assertTrue
class HighlighterTest {
/** Every span of [kind] in [code], as the text it covers. */
private fun spans(code: String, language: Language, kind: Kind): List<String> =
scan(code, rulesOf(language))
.filter { it.kind == kind }
.map { code.substring(it.start, it.end) }
spansOf(code, language).filter { it.kind == kind }.map { code.substring(it.start, it.end) }
private fun assertSpans(
code: String,
@@ -182,6 +180,158 @@ class HighlighterTest {
assertSpans(code, Language.RON, Kind.LITERAL, "3")
}
// Markdown, which has a scanner of its own: what a character means there is decided by where
// it sits rather than by what it is, so most of these are about the cases where it means
// nothing at all.
@Test
fun `a heading is coloured whole and a hash inside a word is not one`() {
val code = "## Layout\nissue #12 is fixed\n#hashtag"
assertSpans(code, Language.MARKDOWN, Kind.KEYWORD, "## Layout")
}
@Test
fun `seven hashes are not a heading`() {
assertSpans("####### deep", Language.MARKDOWN, Kind.KEYWORD)
}
@Test
fun `a fence carries its language as metadata and its body as one string`() {
val code = "text\n```kotlin\nval x = 1\n```\nmore"
assertSpans(code, Language.MARKDOWN, Kind.METADATA, "kotlin")
assertSpans(code, Language.MARKDOWN, Kind.STRING, "```", "val x = 1", "```")
}
/** The state that crosses a line, so the one worth asking about at both ends. */
@Test
fun `a longer fence is not closed by a shorter one, and a heading inside it is not a heading`() {
val code = "````\n```\n# not a heading\n````\nafter"
assertSpans(code, Language.MARKDOWN, Kind.KEYWORD)
assertSpans(code, Language.MARKDOWN, Kind.STRING, "````", "```", "# not a heading", "````")
}
@Test
fun `an unclosed fence runs to the end rather than throwing`() {
assertSpans("```\nstill going", Language.MARKDOWN, Kind.STRING, "```", "still going")
}
@Test
fun `list markers and quote markers colour without their text`() {
val code = "- one\n2. two\n> quoted"
assertSpans(code, Language.MARKDOWN, Kind.MARK, "-", "2.", ">")
}
@Test
fun `a rule and a setext underline are the same mark`() {
assertSpans("Title\n=====\n\n---", Language.MARKDOWN, Kind.MARK, "=====", "---")
}
@Test
fun `emphasis needs something on both sides of it`() {
assertSpans("**bold** and *thin*", Language.MARKDOWN, Kind.LITERAL, "**bold**", "*thin*")
// The case the guards exist for: a C fragment written in a paragraph.
assertSpans("a * b * c and *p = *q", Language.MARKDOWN, Kind.LITERAL)
}
@Test
fun `an underscore inside a word emphasises nothing`() {
assertSpans("snake_case_name and _real_", Language.MARKDOWN, Kind.LITERAL, "_real_")
}
@Test
fun `a code span holds a backtick when opened with two`() {
assertSpans("``a ` b`` and `c`", Language.MARKDOWN, Kind.STRING, "``a ` b``", "`c`")
}
@Test
fun `an unclosed code span is ordinary text`() {
assertSpans("a ` b", Language.MARKDOWN, Kind.STRING)
}
@Test
fun `a link marks its brackets and colours its destination`() {
val code = "see [the plan](PLAN.md) now"
assertSpans(code, Language.MARKDOWN, Kind.MARK, "[", "]")
assertSpans(code, Language.MARKDOWN, Kind.METADATA, "(PLAN.md)")
}
@Test
fun `a table is found by its delimiter row, and pipes elsewhere are plain`() {
val code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph"
assertSpans(
code,
Language.MARKDOWN,
Kind.MARK,
"|",
"|",
"|",
"|---|---|",
"|",
"|",
"|",
)
}
@Test
fun `a table without outer pipes still colours, and the table ends with the rows`() {
val code = "a | b\n--- | ---\nnot a row"
assertSpans(code, Language.MARKDOWN, Kind.MARK, "|", "--- | ---")
}
/**
* The tag in the last case is not an autolink and is left plain, but the address inside it is
* still an address and the bare-URL pass finds it. That is the intended reading: raw HTML is
* not something this scanner knows, and a URL is a URL wherever it was written.
*/
@Test
fun `an autolink colours and an HTML tag does not`() {
val code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">"
assertSpans(
code,
Language.MARKDOWN,
Kind.METADATA,
"<https://example.com>",
"<a@b.com>",
"http://x",
)
}
@Test
fun `a bare URL gives back the sentence's punctuation`() {
assertSpans(
"see https://example.com/a., and ssh://host/x)",
Language.MARKDOWN,
Kind.METADATA,
"https://example.com/a",
"ssh://host/x",
)
}
@Test
fun `a bracket a URL opened itself stays in it`() {
assertSpans(
"https://en.wikipedia.org/wiki/A_(b) here",
Language.MARKDOWN,
Kind.METADATA,
"https://en.wikipedia.org/wiki/A_(b)",
)
}
@Test
fun `a URL inside a link destination is not coloured twice`() {
assertSpans(
"[x](https://example.com)",
Language.MARKDOWN,
Kind.METADATA,
"(https://example.com)",
)
}
@Test
fun `a bracket with no destination after it is left plain`() {
assertSpans("an [aside] here", Language.MARKDOWN, Kind.MARK)
}
@Test
fun `an unknown fence language is drawn plain`() {
assertEquals(null, fenceLanguage("brainfuck"))
@@ -189,8 +339,8 @@ class HighlighterTest {
}
@Test
fun `every alias the fence table knows has rules`() {
Language.entries.forEach { rulesOf(it) }
fun `every language the fence table knows has a scanner`() {
Language.entries.forEach { spansOf("x", it) }
}
/**
@@ -218,11 +368,26 @@ class HighlighterTest {
"0x",
"1.2.3",
"a#b//c/*d*/'e\"f",
"```",
"*",
"**",
"~~",
"> ",
"- ",
"1.",
"[x](",
"#######",
"|",
"|---|",
"<",
"<>",
"http://",
"a://",
"\n\n \n",
)
for (language in Language.entries) {
for (code in nasty) {
val spans = scan(code, rulesOf(language))
val spans = spansOf(code, language)
spans.forEach {
assertTrue(
it.start in 0..it.end && it.end <= code.length,
@@ -0,0 +1,32 @@
package com.example.aiapp
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* What the transcript says where a session ran out of quota.
*
* The pair worth a test is the one that reads the same when it goes wrong: a reset time that
* arrived and one that never did. The second must not turn into a plausible-looking time, because a
* reader has no way of telling an invented one from a reported one.
*/
class LimitRowTest {
private val utc = ZoneId.of("UTC")
@Test
fun `a reported reset time is shown as a time`() {
// 2026-09-05T12:00:00Z. Asserted as a prefix and the clock reading rather than as the
// whole string: the platform's own short-time format is what this asks for, and it
// differs by JDK and locale down to which space character separates the meridiem.
val summary = limitSummary(1_788_609_600.0, utc)
assertTrue(summary.startsWith("Usage limit reached • resets "), summary)
assertTrue(summary.contains("12:00"), summary)
}
@Test
fun `a limit with no reset time says only what is known`() {
assertEquals("Usage limit reached", limitSummary(null, utc))
}
}
@@ -0,0 +1,310 @@
package com.example.aiapp
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.jupiter.api.io.TempDir
/**
* The cache's file logic, which is the half of the transcript cache that can be wrong without
* anything on screen saying so: a page served short, a chunk served across a gap, or a run of lines
* whose recorded coverage does not match what is in it.
*
* Lines here are the shape the server writes -- `{"seq":N,"ts":T,"type":...}` -- because that is
* what the cache reads its two facts off. Nothing parses JSON on either side.
*/
class TranscriptCacheTest {
@field:TempDir lateinit var temp: File
private val said = mutableListOf<String>()
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
private fun session(id: String = "s") = cache().session(TranscriptAddress(id))
private fun line(seq: Long, type: String = "toolStart") =
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
private fun delta(seq: Long) = line(seq, "assistantText")
private fun dirOf(id: String = "s") = File(temp, "v1/host_8443/$id")
private fun names(id: String = "s") = dirOf(id).list().orEmpty().sorted()
private fun write(name: String, lines: List<String>, id: String = "s") {
dirOf(id).mkdirs()
File(dirOf(id), name).writeText(lines.joinToString("\n", postfix = "\n"))
}
private fun seqs(lines: List<String>?) = lines?.map {
Regex("\"seq\":(\\d+)").find(it)!!.groupValues[1].toLong()
}
@Test
fun an_appended_run_is_one_open_chunk_and_its_newest_line_is_the_tail() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
cache.flush()
assertEquals(listOf("1-open.raw.jsonl"), names())
assertEquals(CachedTail(3, line(3)), cache.tail())
assertEquals(listOf(line(2), line(3)), cache.newest(2))
// More than there is is what there is, which is a short opening window and not a failure.
assertEquals(3, cache.newest(80).size)
}
@Test
fun a_gap_in_the_stream_closes_the_open_chunk_under_the_end_it_turned_out_to_have() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
// What a `reset` looks like from here: the next event is not the one after the last.
cache.append(line(90), 90)
cache.flush()
assertEquals(listOf("1-4.raw.jsonl", "90-open.raw.jsonl"), names())
// Nothing is served across the gap: the suffix is the newest chunk alone.
assertEquals(listOf(line(90)), cache.newest(80))
assertEquals(CachedTail(90, line(90)), cache.tail())
}
@Test
fun an_event_already_covered_is_not_written_again() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
cache.append(line(2), 2)
cache.flush()
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
}
@Test
fun an_adjacent_page_extends_the_suffix_and_a_gap_stops_it() {
val cache = session()
(100L..102L).forEach { cache.append(line(it), it) }
cache.flush()
// Adjacent: its end is the open chunk's first.
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
assertEquals(listOf(98L, 99L), seqs(cache.page(before = 100, limit = 2, rows = false)))
assertEquals(60L, seqs(cache.newest(80))?.first())
// Behind a gap: kept on disk, because paging usually closes the gap, but never served
// across it.
assertTrue(cache.storePage((1L..9L).map { line(it) }, 1, 10, rows = true))
assertNull(cache.page(before = 10, limit = 5, rows = false))
assertEquals(60L, seqs(cache.newest(200))?.first())
}
@Test
fun a_page_that_overlaps_what_is_here_is_not_stored() {
val cache = session()
cache.append(line(100), 100)
cache.flush()
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
assertFalse(cache.storePage((50L..79L).map { line(it) }, 50, 80, rows = true))
assertFalse(cache.storePage(emptyList(), 40, 60, rows = true))
assertEquals(listOf("100-open.raw.jsonl", "60-100.rows.jsonl"), names())
}
@Test
fun a_miss_is_null_and_never_an_empty_page() {
val cache = session()
(100L..102L).forEach { cache.append(line(it), it) }
cache.flush()
// At or below where the run starts, so what the reader is scrolling into is the server's.
// An empty list here would be read as the start of the conversation and would stop the
// transcript scrolling back at all.
assertNull(cache.page(before = 100, limit = 40, rows = true))
assertNull(cache.page(before = 40, limit = 40, rows = true))
assertNull(session("never-visited").page(before = 100, limit = 40, rows = true))
}
@Test
fun a_page_starts_from_anywhere_inside_the_run_not_only_at_a_boundary() {
val cache = session()
(1L..10L).forEach { cache.append(line(it), it) }
cache.flush()
// Where a warm open leaves the cursor: in the middle of the live run, because the screen
// drew the newest lines of it. A cache that could only answer at a chunk boundary would
// send this to the server -- and the page that came back would overlap the run and be
// thrown away, so the whole of the scroll back would be fetched again on every visit.
assertEquals(listOf(5L, 6L, 7L), seqs(cache.page(before = 8, limit = 3, rows = false)))
assertEquals((1L..7L).toList(), seqs(cache.page(before = 8, limit = 99, rows = false)))
}
@Test
fun a_page_counted_in_rows_folds_each_delta_run_into_one_and_cuts_only_between_rows() {
val cache = session()
// Two replies of three deltas each, split by a tool call: the same fixture as the
// server's `coalescing_counts_rows_and_joins_delta_runs`.
val lines =
listOf(delta(1), delta(2), delta(3), line(4), delta(5), delta(6), delta(7), line(8))
write("1-9.raw.jsonl", lines)
cache.append(line(9), 9)
cache.flush()
// Three rows: the tool call at 8, the run 5..7, and the tool call at 4. The cut lands
// between rows, so the older run is not started.
assertEquals(
listOf(4L, 5L, 6L, 7L, 8L),
seqs(cache.page(before = 9, limit = 3, rows = true)),
)
// One row is one whole run, however many deltas it is made of.
assertEquals(listOf(8L), seqs(cache.page(before = 9, limit = 1, rows = true)))
// A page of lines counts lines, which is what the anchor restore asks for.
assertEquals(listOf(7L, 8L), seqs(cache.page(before = 9, limit = 2, rows = false)))
}
@Test
fun a_row_page_crosses_a_chunk_boundary_and_stops_short_at_the_oldest_chunk() {
val cache = session()
write("5-9.raw.jsonl", listOf(delta(5), delta(6), line(7), delta(8)))
cache.append(delta(9), 9)
cache.append(line(10), 10)
cache.flush()
// A run straddling the boundary is one row, as it will be once folded.
assertEquals(listOf(8L, 9L, 10L), seqs(cache.page(before = 11, limit = 2, rows = true)))
// Asking for more rows than the suffix holds is a short page, not a failure and not a
// claim that the conversation starts here.
assertEquals((5L..10L).toList(), seqs(cache.page(before = 11, limit = 40, rows = true)))
}
@Test
fun the_floor_for_a_fetch_is_the_nearest_chunk_at_or_below_it() {
val cache = session()
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
write("10-40.rows.jsonl", (10L..39L).map { line(it) })
cache.append(line(90), 90)
cache.flush()
// The run behind the gap, which is what makes the fetched page adjacent to it: a page
// fetched before 90 with a floor of 39 stops at 40 and closes the gap exactly.
assertEquals(40L, cache.coveredUpTo(90))
assertEquals(40L, cache.coveredUpTo(41))
assertEquals(10L, cache.coveredUpTo(10))
// Nothing at or below the oldest chunk's start, so the page is bounded only by its limit.
assertNull(cache.coveredUpTo(9))
}
@Test
fun a_newest_chunk_that_is_not_raw_discards_the_session() {
val cache = session()
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
// Only reachable by dying between closing one live run and opening the next, and there is
// no cursor to be read off a coalesced line -- so the open is a cold one.
assertNull(cache.tail())
assertFalse(dirOf().exists())
}
@Test
fun a_half_written_last_line_is_dropped_and_the_file_repaired() {
val cache = session()
dirOf().mkdirs()
File(dirOf(), "1-open.raw.jsonl").writeText(line(1) + "\n" + line(2) + "\n" + """{"se""")
assertEquals(CachedTail(2, line(2)), cache.tail())
assertEquals(line(1) + "\n" + line(2) + "\n", File(dirOf(), "1-open.raw.jsonl").readText())
// And the run continues from where the good tail left off.
cache.append(line(3), 3)
cache.flush()
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
}
@Test
fun damage_anywhere_else_discards_the_session_when_a_read_reaches_it() {
val cache = session()
write("1-open.raw.jsonl", listOf(line(1), "not ours", line(3)))
// Not seen by the tail, which reads the newest line and stops -- reading a chunk from its
// end is exactly not reading the rest of it, and that is what keeps a warm open cheap on
// a conversation of tens of megabytes.
assertEquals(CachedTail(3, line(3)), cache.tail())
// Reached by a read that walks past it, and there is no honest way to say what a chunk
// covers with a line of it unreadable -- so what is served is nothing, and the session
// opens cold from here on.
assertEquals(emptyList(), cache.newest(80))
assertFalse(dirOf().exists())
assertTrue(said.any { it.contains("damaged") })
}
@Test
fun a_name_this_does_not_recognise_is_ignored() {
val cache = session()
write("notes.txt", listOf("hello"))
write("1-open.raw.jsonl", listOf(line(1)))
assertEquals(CachedTail(1, line(1)), cache.tail())
}
@Test
fun a_chunk_larger_than_one_read_block_is_walked_across_the_boundaries() {
val cache = session()
// Well past the 64 kB block the backwards reader takes at a time, so a page has to be
// stitched across several of them -- including a line that straddles a boundary, which
// is the case nothing else here would notice going wrong.
val padding = "x".repeat(300)
val lines = (1L..500L).map { """{"seq":$it,"ts":1.5,"type":"toolStart","id":"$padding"}""" }
write("1-open.raw.jsonl", lines)
assertEquals(500L, cache.tail()!!.seq)
assertEquals(lines.takeLast(80), cache.newest(80))
assertEquals(lines.subList(0, 400), cache.page(before = 401, limit = 999, rows = false))
// And a non-ASCII line, whose bytes a naive split could cut through a character.
val accented = """{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"""
cache.append(accented, 501)
cache.flush()
assertEquals(accented, cache.tail()!!.line)
}
@Test
fun eviction_takes_the_least_recently_touched_and_never_the_one_on_screen() {
val cache = cache()
listOf("old", "middle", "open").forEachIndexed { at, id ->
write("1-open.raw.jsonl", List(50) { line(it + 1L) }, id = id)
dirOf(id).setLastModified(1_000_000L + at * 1000L)
}
val each = dirOf("old").walkTopDown().filter { it.isFile }.sumOf { it.length() }
// Room for two of the three, so the oldest goes -- and the session being read never does,
// however long ago it was last touched.
cache.evictToBudget(keep = "open", budget = each * 2)
assertEquals(listOf("middle", "open"), File(temp, "v1/host_8443").list()!!.sorted())
cache.evictToBudget(keep = "open", budget = 0)
assertEquals(listOf("open"), File(temp, "v1/host_8443").list()!!.sorted())
}
@Test
fun retaining_deletes_exactly_the_sessions_the_server_no_longer_lists() {
val cache = cache()
listOf("a", "b", "c").forEach { write("1-open.raw.jsonl", listOf(line(1)), id = it) }
cache.retainOnly(setOf("a", "c"))
assertEquals(listOf("a", "c"), File(temp, "v1/host_8443").list()!!.sorted())
}
@Test
fun size_and_purge_are_the_two_halves_of_the_reload_button() {
val cache = session()
assertEquals(0L, cache.bytes())
(1L..5L).forEach { cache.append(line(it), it) }
cache.flush()
assertTrue(cache.bytes() > 0)
cache.purge()
assertEquals(0L, cache.bytes())
assertNull(cache.tail())
// And the session is usable again straight afterwards, which is what a reload does next.
cache.append(line(9), 9)
cache.flush()
assertEquals(listOf(9L), seqs(cache.newest(80)))
}
}
+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.
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff. Show more