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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

AutoVsync picks Fifo, which every backend supports.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

-1
View File
@@ -1 +0,0 @@
../../.claude/skills/ai-app-rigs
+6
View File
@@ -0,0 +1,6 @@
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
# every crate in this repo into one workspace -- they are deliberately
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
# from the repo root runs xtask/src/main.rs directly.
[alias]
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
-380
View File
@@ -1,380 +0,0 @@
---
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 script that ordinarily runs
`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*. The sandbox's
version also handles `auth login`: it prints an inert Anthropic-shaped URL,
rejects any code except `sandbox-code`, and exits successfully for that one.
- **`/think [seconds]` in an echo session puts up a thinking card**, long
enough to watch it spin before it closes with the span it actually took.
The rest of the turn is the ordinary echo reply, so it is also the rig for
a block and a reply meeting.
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
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** and need nothing typed. The prebuilt llama.cpp lives
outside the repo at `~/.local/opt/llama.cpp-vk` — a **Vulkan** build as of
2026-09-19, replacing the CPU one that was there before — and is symlinked as
both `~/.local/bin/llama-server` and `/usr/local/bin/llama-server`. The second
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.
Two models are downloaded under `~/.local/share/ai-app/models`:
- `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, 639 MB, loads in ~4s. It
calls tools correctly and is the right rig for the driver's shape. Do not
judge *answers* by it — asked for the second line of a file it read from
line 2 and then named the third.
- `ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF/Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf`,
12 GB, ~20s to load, and the only one here with a multi-token-prediction
head. It is the rig for anything about `loading` being a state of its own,
since 20s is long enough to send into.
- `ggml-org/SmolVLM-256M-Instruct-GGUF/SmolVLM-256M-Instruct-Q8_0.gguf`,
175 MB, plus the `mmproj-…` beside it, downloaded 2026-09-20 as the rig for
**vision**: it is the only model here that reads pictures, it loads in
seconds on the CPU, and it described a red circle correctly. The pair is
also what exercises the projector being found beside the weights, and the
projector being kept out of the models a provider offers. Qwen3-0.6B beside
it is the other half of that rig -- the model that answers `refused`.
**A second llama.cpp is installed here, and it is the rig for a custom
build.** `~/.local/share/ai-app/llama/prism/` is Prism ML's fork
(`prism` branch, `~/repos/llama.cpp-prism`, Vulkan, `cmake --install
--prefix`), so discovery finds it as a provider called `llama-cpp-prism`
beside the ordinary `llama-cpp`. It is what exercises that mechanism at all,
and it serves `prism-ml/Ternary-Bonsai-2-27B-gguf` -- ternary packings stock
llama.cpp rejects as unknown types. Rebuild it with
`cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_VULKAN=ON
-DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_INSTALL_RPATH='$ORIGIN/../lib'`, about six
minutes at `-j8`.
**Both of those install flags are load-bearing, and the failure is a session
that never becomes ready.** `llama-server` is a 16 KB launcher against
`libllama-server-impl.so`, so a build whose libraries it cannot find dies at
`exec` with `error while loading shared libraries` -- which reaches the phone
as the model never answering. The runpath has to be set, *and* the libraries
have to be where it points: `GNUInstallDirs` chooses `lib64` on some
distributions (Gentoo's amd64 profiles among them) while the runpath above
says `lib`. `readelf -d bin/llama-server | grep RUNPATH` and
`ldd bin/llama-server | grep 'not found'` are the two-second check after any
install here.
**Which Bonsai packing runs on the GPU is the backend's question, not the
model's.** Measured 2026-09-21 with `llama-bench -p 512 -n 64 -r 2 -fa 1
-ngl 99` on the free card:
| packing | backend | pp512 | tg64 |
| --- | --- | ---: | ---: |
| `PTQ1_0`, 5.53 GiB | Vulkan | 519 t/s | 7.5 t/s |
| `PQ2_0`, 7.21 GiB | **CPU**, 8 cores | unfinished after 9 min | -- |
The fork's Vulkan port covers `PTQ1_0` only -- shaders, a `mul_mat_vec` and
the FWHT included -- so `PQ2_0` has no kernel there and every matmul falls
back to the CPU. That reads exactly like a stuck load: the process sits at
700% CPU for minutes with the card idle. On CUDA and HIP it is the other way
round, since `mmq.cu` guards `PTQ1_0` out of the HIP build (it wants Turing
MMA) and leaves `PQ2_0` in. **ROCm cannot be tested in this VM**: there is no
`/dev/kfd`, because the GPU here is virtio-gpu rather than a passed-through
card.
7.5 tok/s is the honest speed of that Vulkan kernel, against 42 for the
IQ3_S 27B beside it -- smaller weights, slower decode. Nothing is
misconfigured; the fork's fast kernels are CUDA and Metal.
**Do not test with a 2-bit quant**: the IQ2_XXS of the 0.6B produces fluent
nonsense, which reads exactly like a broken driver — `llama-cli` produces the
same from the file directly, which is how to tell the two apart in a hurry.
**The GPU is shared and llama-server dies loudly when it runs out.** A second
server loading a model while the 27B holds VRAM fails with `radv/amdgpu:
Failed to allocate a buffer` / `MESA: error: buffer allocation failed` and
exits mid-request. `-ngl 0` runs it on the 8 cores instead, which is the way
to test the driver while something else holds the card -- through the app, that
is the model's "Layers on the GPU" set to 0 in the machines tab's provider
view, and `--models-max` above 1 is how two models come to be loaded at once
in the first place.
**Testing tools and MCP without the app**: `llama-server --tools all` publishes
its built-in tools at `GET /tools` and runs one at `POST /tools` with
`{"tool": …, "params": …}` and an `x-tool-cwd` header — so a whole agent loop
is drivable with `curl` and no model at all. The Exa MCP server at
`https://mcp.exa.ai/mcp` answers **without an API key** and needs a
`User-Agent` header (Cloudflare answers 403 without one, which reads as a
refusal rather than a missing header).
There is no second machine, so **ssh this VM to itself**. That is set up
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
machine 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 machine 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`, currently Claude Code or Codex). 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
- **`-np 1` is what makes the MTP draft head pay.** Taken 2026-09-19 on the
27B above, decode speed for a 300-token reply, from `llama-server`'s own
timings rather than the clock:
| flags | tok/s |
| --- | --- |
| plain, any `-np` | 41.5 |
| `--spec-type draft-mtp -np 1` | 61.4 |
| `--spec-type draft-mtp -np 2` (n-max 2) | 65.9 |
| `--spec-type draft-mtp`, default `-np` (4 slots) | 28 |
Draft acceptance is 0.530.73 in every case, so the head is working in all
of them: what changes is that speculating against a KV cache split four ways
is slower than not speculating. A model's preset gets `parallel = 1` unless
its settings say otherwise (the machines tab's provider view, since
2026-09-19), so this is recorded for whoever next sees MTP look broken or
next raises the slot count to answer two sessions at once. `--spec-draft-n-max 2` was
worth another 7% in a single sample and is deliberately *not* passed — one
sample on a virtualised GPU is not a number to hardcode.
- **Prompt processing is the expensive part of a llama turn here, and decode
speed falls only slowly with context.** Taken 2026-09-19 on a free GPU, the
27B with `--spec-type draft-mtp -np 1`, generating 160 tokens each time:
| context | decode | prefill of that prompt |
| --- | --- | --- |
| 88 | 43.4 tok/s (cold) | 21s |
| 1,569 | 55.5 tok/s | (model still warming) |
| 6,068 | 53.2 tok/s | 9.5s |
| 14,068 | 50.3 tok/s | 22s |
So a turn on a long conversation spends tens of seconds before the first
token, and that is what `SessionStatus::Reading` exists to say. The same
sweep on the 0.6B **on the CPU** falls much harder -- 30.1 tok/s at 44
tokens of context to 11.5 at 6,024 -- which is the shape somebody means by
"it gets slower as the conversation goes on". The figure the app draws is
`timings.predicted_per_second`, decode only, so prefill is never mixed into
it.
- **A busy GPU is a model that will not load at all**, not a slow one:
`radv/amdgpu: Failed to allocate a buffer` and `failed to load model` while
something else holds VRAM. A 0.6B that had been decoding at 149 tok/s ran at
16.7 in that window before its server died, so a tok/s figure taken while
the card is shared says nothing about the model.
- **Asking for the head when the file has none is fatal**, not ignored:
`context type MTP requested but model doesn't contain MTP layers` and the
server exits. Without the flag the same file logs `unused tensor
blk.N.nextn.* — ignoring` and runs normally, which is the state to look for
when MTP is silently not happening.
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
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. // the terminal the QR would be printed on.
enroll: "server/enroll-link.sh", enroll: "server/enroll-link.sh",
), ),
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
// with the same release key as "app" above so the two can install
// over each other -- a separate component, not a mode of "app" above,
// because it is a different applicationId (com.example.aiapp.shell)
// built by a different tool from different sources. No `cwd`: it
// defaults to this checkout's root, which both the `cargo xtask`
// alias (`.cargo/config.toml`, resolved relative to the working
// directory cargo is run from) and `cargo xtask apk`'s own publishing
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
// both need.
Apk(
name: "shell",
modes: ["release", "debug"],
build: "cargo xtask apk",
),
], ],
+19
View File
@@ -1,12 +1,20 @@
.gradle/ .gradle/
build/ build/
app/androidApp/build/ app/androidApp/build/
app/shellApp/build/
local.properties local.properties
.kotlin/ .kotlin/
*.iml *.iml
.idea/ .idea/
.DS_Store .DS_Store
server/target/ server/target/
event-model/target/
client-core/target/
android-shell/target/
# E3's native library, built by cargo-ndk straight into the Gradle module
# (RUST.md) -- an artifact, like server/target/ above, not source.
app/shellApp/src/main/jniLibs/
# Server logs from a development run (ai-server.log by convention, # Server logs from a development run (ai-server.log by convention,
# wg-test.log from ./test-wg-tunnel.sh). # wg-test.log from ./test-wg-tunnel.sh).
@@ -21,3 +29,14 @@ certs/
config.ron config.ron
config.json config.json
sessions/ sessions/
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
iris/target/
iris/android-app/target/
# E5's packaging xtask (RUST.md). `build/` above already covers
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
# The repo root has no Cargo workspace, so this is xtask's own
# intermediate working files (target/xtask/apk/...), not a shared one.
xtask/target/
/target/
+227 -426
View File
@@ -1,6 +1,6 @@
# ai-app # ai-app
A phone interface to AI coding sessions (Codex, Claude Code and llama.cpp), A phone interface to AI coding sessions (Claude Code and llama.cpp),
replacing the Claude app for daily use. Rust/Axum backend on the desktop, replacing the Claude app for daily use. Rust/Axum backend on the desktop,
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
between them. between them.
@@ -9,15 +9,7 @@ between them.
its rationale, and what was rejected. Read it before changing anything its rationale, and what was rejected. Read it before changing anything
structural, and update it in place when a decision changes rather than structural, and update it in place when a decision changes rather than
letting this file and the plan become two versions of the truth. This file is letting this file and the plan become two versions of the truth. This file is
the working notes layer: layout, commands, and things that have bitten. the working notes layer: layout, commands, rigs, and things that have bitten.
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and
ssh here, how importing behaves, and the measurements not worth re-taking.
They moved there on 2026-09-04 because they are 12 KB that only matter once
you are actually running one, and this file is sent with every request. Read
it before writing or running a benchmark, driving the UI from a script, or
touching the import screen.
The central design point, worth not undoing by accident: **a session is a The central design point, worth not undoing by accident: **a session is a
child process, translated into one common event model.** A new session type child process, translated into one common event model.** A new session type
@@ -34,177 +26,10 @@ Module-by-module intent is in PLAN.md's "Backend layout".
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc - `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
comment is the HTTP table and the surface's source of truth. comment is the HTTP table and the surface's source of truth.
**A machine's models are served by one shared `llama-server`** (2026-09-19,
`session/llama/router.rs`): started with no `-m`, which makes it a
**router** — it reads a preset file naming models and their flags, starts a
child server per model asked for, and routes by the `model` field in each
request. So a session has no process of its own, two sessions on one model
share one copy of it in memory, and a backend restart adopts one process
rather than one per session. Four things fall out of it and are easy to get
wrong again — a session records the router's pid in its own directory as
`process::Detail::Shared`, and `process::stop` refuses to signal a `Shared`
record, which is what keeps one session ending from unloading everybody's
model; **nothing stops a router on its own**, and the only thing that does
is the machine's provider view (`POST /machines/{id}/providers/{p}/stop`);
how a model is *loaded* is per model on its machine
(`ProviderConfig::model_settings`, `LLAMA_MODEL_PARAMS`) rather than per
session, and saving those settings rewrites the preset, which **unloads**
that model; and the preset is read back before every edit, because a router
adopted from an earlier run is serving sections this process has never seen
and rewriting without them unloads those.
**A machine can have more than one llama.cpp** (2026-09-21,
`machines.rs`): anything at `~/.local/share/ai-app/llama/<name>/llama-server`
or `.../<name>/bin/llama-server` is discovered beside the one on PATH and
becomes a provider called `llama-cpp-<name>`, with its own router, preset
and model settings. That is how a model whose kernels are not upstream is
served -- Prism ML's ternary Bonsai is the one here, built from the
`prism` branch into `~/.local/share/ai-app/llama/prism` -- without the
phone ever naming a command, which is the property this module exists for.
Both providers offer the machine's whole models directory, since which
build reads which packing is not answerable from the file.
**A llama.cpp session runs on its configured machine** (built
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
port the server binds *there* and the port that reaches it *here*, and
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
the command. Three things fell out of it and are easy to get wrong again —
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
because `llama-server` never reads the stdin whose closing ends a CLI and
the same kill left it loaded on the far machine; the model is looked for on
the machine that will serve it, so the spawn screen offers
`GET /machines/{id}/providers/{p}/models` rather than any list of this
backend's own; and the
readiness poll watches the process as well as the port, since a model that
will not load exits in a second and was being reported as "gave up after
300s". See PLAN.md's "Transport" and "llama-server management".
**A llama session has tools and runs the loop itself** (2026-09-19):
`--tools all` gives the router `llama-server`'s built-in set, which it also
*runs* (`GET /tools` for the definitions, `POST /tools` to call one), while
web search comes from an MCP server this backend connects to directly
(`session/llama/mcp.rs`, Exa preset in a discovered provider's
`mcpServers`). Driving the loop is what makes the permission gate ours:
`manual` asks before every call and remembers a tool you answer
"Always allow …" to, `bypassPermissions` never asks, and the allowances are
folded back out of the transcript. Which tools a *session* offers is a
filter applied to those definitions here, not a flag over there: one shared
server has one set, and the filter costs no reload (2,181 tokens of prompt
with all seven, 698 with none). Three more things fall out of it and are
easy to get wrong again — a model change **asks for another model** and
stops nothing, since the one being left may be another session's;
`parallel = 1` unless that model's settings say otherwise, and it is what
decides whether the MTP draft head is a 50% speed-up or a 33% loss; and
`spec-type = draft-mtp` is conditional on the file actually having a head,
because asking for one that is not there makes `llama-server` **exit**.
**A llama session takes a picture only where the model natively reads one**
(2026-09-20): a multimodal model is loaded with the `mmproj` found beside
its weights (overridable per model, `off` included), an attachment rides in
the request as an `image_url` data URI, and nothing at all is done for a
model without a projector. Four things fall out of it and are easy to get
wrong again -- whether a session takes pictures is `/props`'s
`modalities.vision` from the loaded server and never a guess from this side,
with three states because a loading model has not answered yet
(`Images::Unknown` is *offered*, since a control withheld because nobody
could ask is missing from sessions that would have taken it); a message
carrying an image a model cannot read is **stopped rather than stripped**,
refused at the door, at the queue and at the steering boundary, because
`llama-server` refuses the whole request over one part and a message sent
without its picture is a different message; an earlier turn's image folds
into a line of words for a model without vision, so switching models does
not end the conversation; and a projector is filtered out of the models a
provider *offers*, while staying in the machine's own model list.
**A llama session's thinking is drawn** (2026-09-19): `reasoning_content`
becomes `Event::Thinking` deltas closed by an `Event::ThinkingDone` carrying
the span the *driver* measured, and the phone draws a card that spins while
the block is open and says "Thought for 12.4s" once it is not. The reasoning
is deliberately not part of the next prompt (`conversation` ignores it), and
`timings.predicted_per_second` and `timings.prompt_ms` off the same stream
become `UsageDelta`'s `tokensPerSecond` and `prefillMs`, which is the
"read 9.5s · 50.3 tok/s · 3:00 PM" under a finished reply — nothing else here
measures either, so every other driver sends `None`, and the clock is last so
that it does not move when a provider reports fewer of them.
**A wait that can be measured says how far along it is** (2026-09-21):
`GET /sessions/{id}/progress` answers `{of, fraction, stage?}` and `null`
for a session that is not in one -- runtime state, asked for twice a second
by the session screen while it is drawing a wait, and deliberately never an
event, since a load reports five times a second and every event is a
transcript line for ever. The two sources are the router's `/models/sse`
stream, which is the **only** place a model's load progress appears (`GET
/models` says "loading" and no more), and `prompt_progress` chunks that
`"return_progress": true` adds to the generation stream. The sample says
which status it measures so it cannot be drawn under another one, and
`/loading [seconds] [stages]` / `/reading [seconds]` in an echo session are
the rig for the phone's half. **Zero is never reported**, being the absence
of a sample rather than a measurement -- which matters because
`llama-server` reports a model's load as 0 and then 1 with nothing between
(measured 2026-09-21 on both the 0.6B and the 27B), and reports prompt
processing once a batch; so the bar usually draws for a long prompt and not
for a load.
**A turn's wait has two halves and says which** (2026-09-19):
`SessionStatus::Loading` is the model coming off disk and
`SessionStatus::Reading` is `llama-server` processing the prompt -- emitted
when the request goes out and cleared by the first thing the model says, of
any kind. Prefill is the expensive half here (~10s at 6k tokens, ~22s at
14k), and as `running` it looked exactly like thinking. The phone draws
both with the working spinner and its own words, "loading model" and
"reading prompt".
**Thinking effort is a param, and which levels exist is the model's answer**
(2026-09-19): the `thinking` param rides on the request as a chat-template
argument (`reasoning_effort`, or `enable_thinking: false` for `off`), so it
needs no restart -- and the driver asks the loaded server which levels its
template actually takes rather than trusting the offered list, because the
27B raises on `high` and answers to `xhigh`. A level it cannot take is
dropped and said in the transcript, naming the ones it can.
**Every one of those is a default rather than a constant** (2026-09-19):
`DriverKind::params` declares what a provider takes — key, label, shape,
and whether a change waits for a restart — and the phone renders whatever
arrives, on the spawn form and in the session settings dialog. Adding a
setting to a driver is one entry in that table and no app change. `tools`
is in there too, because the seven built-in definitions are ~1,500 tokens
of every prompt, which on a small window is the difference between a usable
session and one that overruns. `DriverKind::model_params` is the same table
for a provider's **models**, drawn in the machines tab's provider view —
the settings that decide how a model is loaded, which belong to the machine
because one loaded copy answers every session using it.
**A model is downloaded onto the machine that will serve it** (2026-09-19,
replacing the fetch this backend used to do onto its own disk, and the
Models tab that went with it). `models.rs` writes a script and a detached
`curl` runs it *there*; the state of a run is a file beside the partial
(`x.gguf.download`), so nothing about it is held in this process — it
survives the phone closing, this backend restarting and a second device
watching, and `kill -0` at each listing is what stops a machine that was
rebooted from leaving a download claiming to be running. The progress is
`wc -c` of the partial against the size HuggingFace published, the sha256
it publishes is what makes a resume safe, and a finished download is not a
state: it is a model, in the list beside the one still going.
Codex is one persistent `codex app-server --stdio` process per session; its
driver uses native turn steering and interruption, persists the protocol
state and thread id, and reads subscription limits through the same CLI
protocol.
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
**Every text field is `LabelledField`** (`Field.kt`): the label is a line `AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
above the box rather than a thing floating inside it, and the padding is one (sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
line's worth. Material's outlined field spends the height of three lines to clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
hold one, which on a form of a dozen settings is a screen and a half of
scrolling. What is *not* shrunk is the value -- the framing is what was
expensive. A field's `hint` is what leaving it blank means, above the box
with the label, because inside it is gone the moment anybody types.
**Session settings is a screen with two tabs** (`SessionSettingsScreen.kt`),
drawn over the session like the file explorer so the session stays composed.
The second tab is `ProviderScreen` itself -- the same composable the machines
tab opens -- so a provider's settings have two ways in and one
implementation; it takes `onBack = null` there, since the screen around it
has one.
`AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that
slides the whole main screen over a session from the left (`MainPanel.kt`)
and what it has running beside the turn -- its background tasks over its
subagents (`BackgroundTasks.kt`, `SubagentPanel.kt`) -- from the right, both keeping
the session composed underneath; `MainScreen.kt` the root's three tabs
(sessions, import, machines); `Reorder.kt` the drag that moves a row of a
lazy list, used by the session list's handles -- **the order of that list is
the reader's own and nothing sorts it** (`POST /sessions/order`);
`MachineModels.kt` the models on one machine
and the downloads putting them there, drawn inside `ProviderScreen.kt` for a
provider that serves files off that machine's disk; `Api.kt`/`EventStream.kt`
the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
the Keystore-sealed token. the Keystore-sealed token.
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA - `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
@@ -214,23 +39,17 @@ Module-by-module intent is in PLAN.md's "Backend layout".
build without it, since it is a path dependency, which is what keeps the two build without it, since it is a path dependency, which is what keeps the two
projects version-locked to the commit this repo pins. What deliberately did projects version-locked to the commit this repo pins. What deliberately did
**not** move is the API surface and the config *schema*: routes, drivers, **not** move is the API surface and the config *schema*: routes, drivers,
sessions and machines are what makes this project itself. sessions and setups are what makes this project itself.
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
choices made there that are still awaiting review.
**The transcript file itself is readable from the session settings dialog**
(2026-09-21): *View raw* opens the file explorer on it, from
`transcriptFile` on `GET /sessions/{id}` -- which names the machine *this
backend* runs on, not the session's. It is the explorer's third caller and
needed no new screen; a transcript past `FILE_LIMIT` (1 MiB) is refused the
way any other large file is.
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and - `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`). `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it - `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
and stream effects in `SessionScreen.kt`. and stream effects in `SessionScreen.kt`.
- `TODO.md` — the working list. - `TODO.md` — the working list.
- `RUST.md` — the plan for moving the app to Rust (on the `rustify`
branch of the `ai-app-2` clone): what has to be reproduced, the
framework decision, and the ordered experiments with their pass
conditions. Read it before touching anything under that branch.
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as - `.dev-updater.ron` — what Dev Updater builds here: the server (run as
`service: Managed(…)`, supervised by Dev Updater's own implementation `service: Managed(…)`, supervised by Dev Updater's own implementation
rather than a script kept here) and the APK, in parallel. It points at rather than a script kept here) and the APK, in parallel. It points at
@@ -323,6 +142,153 @@ two icon buttons the same width without either being given one — and why
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
involved. That is how to verify the wg0-only posture. involved. That is how to verify the wg0-only posture.
## The rigs
Each exists because something was invisible without it.
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
and data directory, holding eight invented Claude Code transcripts and a
`claude` that is two lines of shell. **That isolation is the point**: the
import screen lists whatever is in `~/.claude/projects`, which in this VM is
real agent transcripts, so exercising *delete* against the ordinary server
deletes somebody's conversation and exercising *import* starts a real
`--resume` on the owner's account.
Its port and root derive from the checkout's name, so two checkouts'
sandboxes cannot reach each other, and its token is generated once into
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
the enrolment flow appended — so the emulator app is enrolled **once** (the
start banner prints the command) and stays enrolled. It shares the real TLS
certificates, because the installed APK pins that CA.
Driving verbs, so none of this is re-derived per session:
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
`./ui-sandbox.sh send SID text|@file`, and
`./ui-sandbox.sh api /path [curl args]`.
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
enrolment already there — for when the fixture under test was expensive to
build; plain `start` wipes them, which is right for the list-screen
fixtures and wrong for that.
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
fake CLI slow to start. Both exist because operations that finish in
milliseconds have states on the way that nothing can observe, and an
unobservable state is one where broken and working look identical.
It also builds a fixture tree at the sandbox home's `~/files` for the
explorer, holding the states otherwise only reachable by finding a real
machine in one: an empty directory, a name with a tab and one with an
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
symlink to a directory and a broken one, a source file per language, and
the three sizes the limits were measured against (`edit-32k.rs`,
`edit-128k.rs`, `big-source.rs`). Point a session at it with
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
The explorer's 409 is produced by editing the file on the machine
(`printf … > file`) between pressing the pencil and pressing save.
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
echo driver is the right rig for most things and the wrong one for anything
whose cost scales with what was actually written: a real reply is longer,
is real markdown, and carries tool calls whose input and output are
kilobytes. Two faults were invisible until a real transcript was loaded — a
page of history landing mid-fling threw the reader back to the newest end,
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
`-b` takes the biggest conversation on the machine rather than the newest,
which is what a scrolling test wants; `--stop` takes it down.
It copies the transcript into `/tmp` and gives the server a `HOME` of its
own, so the import can only see the copy — importing spawns `claude
--resume`, and against the real file that is a second CLI writing to a
conversation somebody may still be in. **A transcript never goes in this
repository**: they hold whatever was said, read and written in that
session, and `~/repos` is shared with the host besides.
- **A fake CLI exercises the process lifecycle without a token.** Point a
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
it holds the fifo open, records a real pid, writes nothing, and dies on a
signal. So adopt, stop, restart and start are all drivable without a real
`--resume` and without spending a turn on somebody's account. Reach for
this when what is under test is *whether a process is running*, and for
`debug-transcript.sh` when it is *what the transcript draws*.
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
the first session (or `-k` keeps the current screen), scrolls a fixed
gesture loop, and prints the app's render report — the same one the in-app
copy button produces, whose `on screen:` line names what the viewport was
holding. Compare two runs with the same gestures; the emulator's absolute
frame times transfer nothing, the report's accounting does. Run it either
side of any change under `Markdown*.kt`, `Transcript*.kt` or
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
that move first are the worst `record: one block`, the reparse mean while
streaming, and the draw phase's accounting line.
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
resets the report, sends FILE, waits for the transcript to stop growing,
and prints. Both of those are corrections to a first version that measured
nothing: a transcript parked further back never redraws while a reply
streams into it, and a session is idle at *both* ends of a turn, so polling
for idle answers before the turn has started.
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
framework, from `atrace` text output with no trace processor needed. It is
how the cost of a layout node per link was attributed to the framework
rather than guessed at.
### Driving the UI
**No script that drives this app's UI presses a coordinate.** Every control
is found by the name it already carries for assistive technology —
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
against the screen at the moment of the gesture and fails the whole run when
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
coordinate is a position measured once by hand, and anything that moves the
control makes the tap land on whatever now sits there — the bench then
reports a number that was never measured, which reads exactly like a result.
Both bench scripts pressed the render report at `tap 723 205` until that
button moved into the session settings dialog on 2026-09-03. The check that
none has crept back:
grep -n "tap [0-9]" app/*.sh
Swipes are still coordinates, deliberately: a gesture across a scrolling area
is a distance rather than a control.
**Two traps in the emulator bench loop**, each of which cost a run.
`adb shell pm clear` removes the enrolment and the notification permission
along with the saved anchors, so the next run measures a permission dialog —
re-enrol with the command `ui-sandbox.sh` prints, and
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
so the only way two builds start a scroll from the same place is a *fresh
session for each*.
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
`emu up` creates and boots the AVD named after this checkout — whatever `emu
name` prints, never a name typed out here, since this file is the same in
every clone. `run-android.sh` is that plus a build and an install. The `adb`
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
script from `emulator-tools` runs `emu check` before `installDebug`,
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
to every attached device; when it refuses, say which device you mean at the
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
### Testing llama.cpp and ssh here
The prebuilt CPU llama.cpp lives outside the repo at
`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its
own directory on `LD_LIBRARY_PATH`, so start the server as
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
broken driver — `llama-cli` produces the same from the file directly, which
is how to tell the two apart in a hurry.
There is no second machine, so **ssh this VM to itself**: generate a
throwaway key, append the public half to `~/.ssh/authorized_keys`, and
configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches
nothing real. Point a provider's `command` at something harmless like
`/bin/echo` rather than at `claude`: the transport is what is under test, the
process exiting immediately is the signal, and it costs no tokens. **Take the
key back out afterwards.** The remote login shell here is **fish**; the
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
both, but that is luck rather than design, and a shell that is neither is the
thing to suspect first if a remote spawn ever mangles an argument.
## Where things run (host vs this VM) ## Where things run (host vs this VM)
The machine itself — the two boxes, the shared `~/repos` mount, and why the The machine itself — the two boxes, the shared `~/repos` mount, and why the
@@ -331,7 +297,7 @@ means here:
- **`ai-server` belongs on the host in production.** That is where the LAN - **`ai-server` belongs on the host in production.** That is where the LAN
address the phone can reach is, and where WireGuard terminates. address the phone can reach is, and where WireGuard terminates.
`wg-machine-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it `wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
there with `sudo WG_ENDPOINT=<ddns name>`. there with `sudo WG_ENDPOINT=<ddns name>`.
- **The tunnel and the real phone can never terminate in the VM**, because - **The tunnel and the real phone can never terminate in the VM**, because
nothing outside can open a connection into it. Phone bring-up is host work. nothing outside can open a connection into it. Phone bring-up is host work.
@@ -364,99 +330,47 @@ day to day:
keep what a development server spawns. The flag decides only what **new** keep what a development server spawns. The flag decides only what **new**
sessions are marked as; what happens on the way out is decided by the sessions are marked as; what happens on the way out is decided by the
**mark**. **mark**.
- **A llama.cpp router is not cleaned up by any of that**, throwaway sessions
included: it belongs to the machine rather than to a session, and a
development server that has loaded a model leaves it loaded — gigabytes of
VRAM — after `pkill ai-server`. Stop it from the machines tab's provider
view, or `pkill -f "[l]lama-server"` when testing.
- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and - Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and
`stderr.log`. `stdout.log` is the driver's input, read from the byte offset `stderr.log`. `stdout.log` is the driver's input, read from the byte offset
in `process.json`; removing either by hand while the session is live loses in `process.json`; removing either by hand while the session is live loses
output or replays it. output or replays it.
## Auto-resume ## Importing
**A session switched to it sends itself a message once the account's usage The import list reports each session's **size as well as its line count**,
limit lifts** — off by default, per session, in the session settings dialog. because the two disagree in the way that matters: these transcripts embed
PLAN.md's "Auto-resume" is the design; day to day: screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
count tells you what continuing a session will cost. Shown, not warned about;
importing a large session is a choice somebody is entitled to make.
- **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time, **Never import a Claude Code session that is open in a terminal.** The app
asks `GET /usage`'s meter for that machine and provider, and only sends when refuses it — see PLAN.md for the incident that made that a refusal rather
it answers `ok` with nothing at 100%. Anything else — still spent, logged than a warning.
out, unreachable — is a longer wait, and a still-spent window reschedules to
the reset time the *meter* now gives.
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
the same `limitReached` event a real driver does, and `/usage 100 5` sets
what the meter answers. They are deliberately separate: the two disagreeing
is the case the design exists for. `/usage 20` is the limit lifting.
- The wait is on the session in `config.ron` (`resume`), so it survives a
backend restart. A day after the limit was hit it gives up and says so in
the transcript.
## A session waiting on its own work **One Claude Code session id can name two files, and the listing offers it
once.** Resuming from a different working directory makes the CLI write a
second transcript with the same id under that directory's project folder — an
ordinary state of a machine, not corruption. Everything downstream addresses
a session by id, and the phone keyed its list on it, so two rows sharing one
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
the copy with the most lines, because the other is usually a few-hundred-byte
stub and is often the *newer* of the two, so recency is the wrong key.
Deleting removes every copy rather than the first, or the row came back after
a delete that reported success. The phone's half is `uniqueItems`, which
every list keyed on a server-chosen id goes through: a repeat there must
never be able to close the app, whatever produced it.
Since 2026-09-06 a session whose turn ended with a **backgrounded subagent or **Deleting a session offers to take the machine's own transcript with it** —
command still running** reports `waiting` rather than `idle` — its own status, `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
drawn as the word "waiting" in `waitingColor` on both screens. `idle` means confirmation, and only where the driver keeps a record of its own
"waiting for a person" and this means the opposite, so it also suppresses the (`keepsOwnTranscript`, which today means Claude Code). Off by default,
"finished" notification, which used to arrive at the one moment it was untrue. because leaving that copy is what makes an ordinary delete recoverable — and
Two things fall out of it and are easy to get wrong again: the queue and the the dialog's paragraph is rewritten when it is on rather than appended to,
held-command boundary release on **either** end-of-turn status, so a message since the sentence promising the conversation "should still be there to
sent while a subagent runs is not held until the subagent finishes; and import again" is exactly the one the switch makes false. The server deletes
`sessionWorking("waiting")` is deliberately **false** — nothing is being the machine's copy *first*, so a machine it cannot reach leaves the session
written, and the fold uses that same predicate to decide a reply is settled. where it was instead of half-deleted.
- **Nothing subagent-specific goes in the main agent's transcript** unless a
subagent sends it a real message that wakes it — which is the peer path, and
already has a row. A row per finished background task was tried and was a
screenful of dividers about work nobody was asking after, one of them a whole
shell command. A subagent's report is its own transcript's closing text and
is read in the subcard.
- **A backgrounded command has no subagent, so its report lands in the tool
card that launched it** — a `ToolUpdate` against the call's own id, replacing
the launch result that says it is still running. `/background [seconds]` in
an echo session is that shape end to end.
- **Two replies that meet are separated by a `TurnBreak`** — a hairline, no
words. The reply that follows a turn boundary is a **new** message: the fold
refuses to grow a settled reply, and without that the two ran together
mid-sentence. `./ui-sandbox.sh` plus `/subagent 3` or `/background 5` in an
echo session is the whole rig; the helpers stagger a second apart so each
reply is its own.
- **Claude's background-task level is authority; two edge sources are the
fallback.** Since Claude Code 2.1.261,
`background_tasks_changed { tasks: [...] }` replaces the live set and repairs
a missed ending edge. Its array size is also the measured `backgroundTasks`
count exposed on the session row and event stream; the phone draws a nonzero
count beside the status rather than deriving one from `waiting` or from the
subagent directory. **What those tasks are is `GET /sessions/{id}/background`**,
listed in the session's right panel above the subagents: runtime state, so it
is never persisted and `null` -- not an empty list -- is what a session with
no process answers. **A task is drawn as the command it ran, and tapping it
goes to the call that started it** -- both from the transcript rather than
from the provider: a driver reports which tool call its task belongs to and
`Session::background_tasks` resolves that id into a seq and, for a provider
that says nothing (Codex names a terminal by a process id), the command on
the call. The phone travels there with `travelTo`, the same journey a
reopened session makes to put a reader back where they stopped. An `ambient` task is dropped from both the list and the
count, on the CLI's own instruction: a live-update watcher is not activity,
and counting one leaves a session `waiting` for ever. An adopted CLI is sent a repeated `initialize` to ask
for the current set. Reconcile only between turns or at a result boundary:
a foreground agent is legitimately absent from a background-only snapshot.
Older CLIs still need both edge sources: `open_tasks` knows about a
backgrounded command, while `Subagents::any_open` finds a subagent whose
`task_started` is behind an adopted stdout offset.
- **A usage limit a subagent hits reaches the session**, not just the
subagent's own transcript; auto-resume can only schedule against a session.
That is the case where the main agent is idle and a background Task is
still burning quota.
- **Codex's count is two id sets added together.** Open child thread ids come
from the subagent registry; live background command process ids come from
app-server's experimental `thread/backgroundTerminals/list`. The command set
is runtime state, refreshed at lifecycle edges and once a second while
nonempty. Never decrement it from an unmatched completion.
- **The status word and its colour are `sessionStatusWord` /
`sessionStatusColour`**, shared by the list and the session screen. They
were two `when`s, and the second one silently missed `waiting`.
## Shared appearance ## Shared appearance
@@ -469,136 +383,10 @@ written, and the fold uses that same predicate to decide a reply is settled.
swallowed the drag along with the tap, so a list could not be scrolled swallowed the drag along with the tap, so a list could not be scrolled
while anything in it was busy. while anything in it was busy.
- **A rate-limit bar belongs to a session's provider, not to its machine.**
One machine offers echo, the Claude CLI and a local model at once and only
the CLI spends anything, so a session says which meter reports on it
(`usageProvider`, from `DriverKind::usage_provider`, which
`usage::providers_for` reads too so the two lists cannot disagree) and the
phone matches a snapshot on machine *and* provider. Nothing meters a llama
or echo session, and the phone draws **nothing** for one — not a zero, and
not "unknown". Nothing while the first fetch is out either: "checking"
under a session that turns out to meter nothing is a row the screen then
has to withdraw.
## Things that have bitten ## Things that have bitten
- **A server started with no `--tools` answers 403 at `GET /tools`, not an Project-specific only — a lesson that would bite any project on this machine
empty list.** The route is off rather than empty, so reading that as a belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
failure made "no tools" — the one setting whose entire purpose is to have
none — a session that never started. The router is always given
`--tools all` now and the choice is a filter here, so this is a trap for
whoever next changes how the server is started.
- **`POST /models/load` answers 400 for a model that is already loaded**, and
that is the *ordinary* case once one server is shared: a second session
naming a model somebody else loaded. The router driver asks what is loaded
first and treats "it is there" as the answer whatever the request said.
- **Starting a process from a blocking thread needs the runtime.** Loading a
model is minutes of disk, so it runs on a `std::thread` — and tokio's
`Command::spawn` registers the child with the reactor, so calling it with no
runtime context panics. The panic kills only that thread: the session said
`loading` for ever and nothing appeared in the log. `Routers` holds a
`tokio::runtime::Handle` and enters it around the spawn.
- **A llama session reports `loading`, and a message sent into it queues.**
Before 2026-09-19 the session showed `running` from the moment the process
started, so a minute of reading a model off disk was indistinguishable from
a minute of thinking -- and anything sent in that window came back as an
error, because `llama-server` refuses everything until the model is in
memory. `SessionStatus::Loading` is the state. A driver that reports
`Loading` owes the holding as well as the word, and **the queue is where it
holds**: held inside the turn instead (until 2026-09-20) the message was
recorded as read on arrival, so the phone drew it as sent while nothing was
reading it, and the turn then folded it out of the transcript *and*
appended it, sending it to the model twice. `Shared::await_ready` is now
only for a turn whose model was changed under it.
- **A llama turn that says nothing said something that was thrown away.** Two
silent endings were found on 2026-09-20 and both looked, on the phone, like
a message that was sent and never answered: an `{"error": ...}` chunk
arriving mid-stream on an otherwise successful response (a GPU that ran out
of memory mid-decode), and a stream that simply stops without its `[DONE]`
(the model unloaded under the session). Neither is an ordinary end, and
`generate` now fails the turn for both -- a reply that stops early is not a
reply, and the transcript keeps whatever arrived before it.
- **`<__media__>` in a llama prompt is a picture, wherever it came from.**
llama.cpp takes an image out of the request and leaves that marker in the
rendered text, then pairs each marker with a decoded image at tokenize time
-- so one in words nobody attached a picture to fails the turn with `number
of media markers in text (1) exceeds number of bitmaps (0)`, which reaches
the phone as "Failed to tokenize prompt". A model saying it back is enough,
and then *every* later message fails too, since the conversation is folded
out of a transcript that now holds it. `Message::new` and
`Message::from_user` take it out of anything that is text (`without_marker`),
which is every message with words in it.
- **A cancel flag is only as prompt as the next place somebody looks.** A
llama turn waits on three things that look nowhere at all: a permission
question, a tool call `llama-server` is running (a shell command there runs
to its own timeout, up to a minute), and the completion itself, which says
nothing for as long as the prompt takes to read -- tens of seconds on a long
conversation. Setting `cancel` left the turn exactly where it was until
whichever it was came back, so Pause did nothing on screen for all of it.
**The wait is what ends, not the work**: `awaiting` runs each of those on a
thread of its own and `Shared::abandon_turn` answers the wait, so the turn
ends in milliseconds (measured 43ms in every state) and the abandoned thread
finishes into a channel nobody is reading. Nothing here can stop a shell
command or a model mid-reply, and pretending otherwise is what the old code
did.
**Which is why cancellation is a token per turn** (`Cancel`), not a flag on
the session: the abandoned thread wakes up some time later, and a flag the
next turn had reset would let it write into a conversation it is no longer
part of. Its own token stays set for ever, so it says nothing -- and the
open thinking block is closed by `Shared::abandon_turn` rather than by that
thread, since the one that knows is not the one that ends the turn.
- **Stop ends a llama session and takes the model with it, if nobody else
wants it** (2026-09-21). Until then Stop did *nothing at all* to one:
`stop_session` signals the session's recorded process, and `process::stop`
refuses a `Shared` record -- so the session sat at `idle` with no sign
anything had happened. A `Shared` record now routes to `Driver::stop`,
because what stopping means for a session that borrows somebody else's
process is the driver's to say. The llama driver ends the turn, says
`exited` itself (nothing else will -- there is no process of its own to
die), and asks `Router::release`: each live session claims the model it is
on, and the model comes out of memory only when the last claim goes. A model
another session is using stays.
- **A path is stored as it was typed, and `~` is expanded where it is used.**
`~/repos/x` and `/home/someone/repos/x` are a path and a snapshot of where it
pointed, and the snapshot is what breaks when an account is renamed or the
value is read on another machine -- so nothing at the boundary rewrites one
in either direction (`machines::tidy` used to expand and `shorten_home` used
to contract; both are gone). Expansion belongs to the machine the path is on:
`ssh::quote_path` and `files::PATH_PRELUDE` for a remote one,
`ssh::expand_home` for one here. The exception that proves it is
**`llama-server`'s tools**, which take the working directory as an
`x-tool-cwd` header and `chdir` to it with no shell in the way: a `~` arrives
there as a directory of that name and *every* tool using one answers "failed
to spawn process\n[exit code: -1]", which on the phone looks like a session
whose tools are all broken. `files::resolve_blocking` is what the llama
driver resolves it with at launch, on the machine that will serve the
session.
- **A transcript outlives the enum.** Removing `Event::TaskNote` hours after
adding it made every transcript that had recorded one unreadable, so
`launch` failed for those sessions and `SessionManager::new` skipped them —
no status, nothing sendable, no new messages, for every live session that
had run a background task. **The set of kinds a transcript can hold only
ever grows**: a line may come from a newer server or from an older one that
wrote a kind since dropped, and one unfamiliar word must never be able to
end the file. `Indexed::parse_at` degrades a line it cannot read to
`Event::Unreadable { kind }`, keeping its seq — which is what everything
downstream is addressed by — and the phone draws it as a placeholder saying
which kind. Never delete a variant instead of retiring it; `Event::TaskNote`
is what retiring looks like, and the phone folds it to no row.
Project-specific only. A lesson that would bite any project on this machine
belongs in `~/.claude/MACHINE.md` or the `this-machine-*` skill for its
subject; one that would bite any project anywhere belongs in the
`code-lessons` skill, under the admission test at its end.
- **tracing caches callsite interest process-wide.** A test that hits a - **tracing caches callsite interest process-wide.** A test that hits a
`tracing::warn!` with no subscriber installed can poison the interest cache `tracing::warn!` with no subscriber installed can poison the interest cache
@@ -692,19 +480,6 @@ subject; one that would bite any project anywhere belongs in the
the reader hit the end of what was loaded on every swipe and stood there the reader hit the end of what was loaded on every swipe and stood there
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
is actually on screen. is actually on screen.
- **A page landing while the history observer was fetching it must trigger its
own successor.** The observer once collected only `LazyListState.layoutInfo`;
while its collector was suspended in `loadOlderPage`, a compact page could
be composed and laid out without leaving another change to observe afterward.
Keying the effect on `oldestSeq` still missed the opening prefetch: that key
changed while `loadingHistory` was true, so the restarted effect declined to
overlap it and never noticed the flag returning to false. Codex exposes both
failures because a page full of calls collapses into one tool group: loading
stopped until expanding that group forced a layout. The observer now collects
the cursor, loading, restoring and failure state with the layout, so returning
to not-loading always rechecks the settled height. A failed page turns the
history boundary into a Try again control rather than retrying in a loop or
requiring another scroll.
- **Only `fetchTranscript` was off the main thread; the fold was not.** - **Only `fetchTranscript` was off the main thread; the fold was not.**
`foldEvent` returns a new list per event, so a page is that many copies of `foldEvent` returns a new list per event, so a page is that many copies of
a growing list — fine at 80 events and about 300,000 element copies at 800, a growing list — fine at 80 events and about 300,000 element copies at 800,
@@ -712,12 +487,38 @@ subject; one that would bite any project anywhere belongs in the
shape: the `markdownIn` scan that decides *what* to parse ran before the shape: the `markdownIn` scan that decides *what* to parse ran before the
hop to `Dispatchers.Default`. The shape to watch for is a `withContext` hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
that wraps the *fetch* and leaves the work done with the result outside it. that wraps the *fetch* and leaves the work done with the result outside it.
- **A transcript snapshot cannot survive a suspension and then be assigned.**
`loadOlderPage` joined its page to `items`, suspended while `warm` parsed ## Measurements worth not re-taking
markdown, and then assigned the joined snapshot. An SSE event arriving in
that gap appeared and vanished; reopening brought it back because the - **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
transcript and cache had it all along. Warm against a candidate if needed, emulator against a real imported transcript with the server at
then join against the current `items` and assign without another suspension. `--delay 120`. Settled and flinging fast, both into fresh history and back
Also keep the page's original `oldestSeq`: a stream reset while the fetch or through rows already drawn: **5.25.9% janky frames, 99th percentile
warm is suspended makes the page stale, and it must be discarded rather 2932ms, 02 slow UI-thread frames.** The stock Settings app on the same
than joined into the reset window. device is 3.3% and 38ms, so this is at the platform floor. The number that
is *not* at the floor is the first few seconds after opening a session,
where every row on the way is being composed for the first time; that is
inherent to a lazy list and it is why a measurement taken before the screen
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
- **The reset path is not reachable by reopening a session.** Measured
2026-09-04 against a session streaming at 20 events a second: reopening one
with an anchor 1,800 events back connects **87119 events behind**, well
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
opening page, then one span covering the whole distance. To exercise the
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
the app takes the reset on a live connection, clears, refills and carries
on without reconnecting.
- **The session screen's stream survives backgrounding here** — 20 seconds at
the launcher while 415 events were produced brought no reconnect at all,
which is not what the comment above that loop expects, and is most likely
this emulator being headless rather than the phone's behaviour.
- **Reopening a cached session costs one request for one event** (the probe),
and scrolling the whole conversation back costs nothing more; a cold open
of the same 500-event session is two pages, 100 events. Measured
2026-09-04 on the emulator against the sandbox.
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
28,000-line file because it draws one row per line; the editor is one
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
screen. If you make the editor faster, that number is what to move.
EXPLORER.md's "What the measurements said" has the rest.
+146
View File
@@ -0,0 +1,146 @@
# client-core
`client-core/` is the app's pure logic held once instead of twice, per
RUST.md's recommendation item 1. It is a plain Rust library crate with no UI
framework dependency of any kind, so it can outlive whichever one the app
ends up drawing with (Masonry, iris, or something else -- see RUST.md).
`event-model/` is its sibling: the wire shape both this crate and `server/`
share, extracted from `server/src/session/driver.rs` and
`session/transcript.rs` on 2026-09-04.
Neither crate is wired into anything yet. `server/` re-exports `event-model`
so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core`
has no caller -- it exists for whichever experiment in RUST.md picks it up
next (a Masonry or iris transcript screen, most likely).
## What's here, and what Kotlin file it replaces
| `client-core/src/…` | Kotlin original | Status |
|------------------------------------------|-------------------------------------------|--------|
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
| `api.rs` | `Api.kt` | Partial -- see below |
| `event_stream.rs` | `EventStream.kt` | Done |
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
| *(not started)* | `TranscriptSource.kt` | Not started |
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
cases ported alongside it, plus new tests for the pieces that had none
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
types carry no logic of their own to test -- `server/`'s own tests exercise
them via `session::transcript`'s round-trip coverage).
## Correspondence notes worth knowing before touching either side
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
non-overlapping spans. Whatever UI framework ends up consuming this
crate maps `Style` onto its own text-styling type; nothing here should
change to accommodate a particular one.
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
strings. `highlight::span_text` turns a `Span` back into text for a
caller working the same way; a caller that wants byte offsets into a
`&str` has to convert.
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
translation bug while it was being written: an early draft let a
*damaged* chunk (one file unreadable, discard just this session) and a
genuine I/O failure (disk gone, disable the whole cache) both surface as
the same `Err` from one closure, which would have disabled every
session's cache over a single corrupt chunk. Fixed by checking a
thread-local "was this damage" flag before deciding which failure mode
it was -- see the comment on `guard` and the commit message for
`transcript_cache.rs`.
## What `api.rs` covers, and what it does not yet
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
`ApiClient` and `event_stream::follow_session_events` are tested with a
fake transport and no server). `UreqTransport` is the only real
implementation, backed by `ureq` -- see its Cargo.toml comment for why
(blocking, already a project dependency, no extra TLS crate needed since
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
Covered: session list/read, message send, unqueue, answer, interrupt,
stop, start, rename, cwd, model, permission-mode, notify, command,
compact, delete, and one transcript page.
**Not covered, and each is real work rather than a stub to fill in:**
setups (`/setups*`, machine and provider discovery), the file explorer
(`/setups/{id}/dir|file`), usage (`/usage`), models
(`/models*`, HuggingFace browsing and downloads), attachments
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
the full table to work from when one of these is next.
## What `transcript_fold.rs` covers, and what it does not yet
`fold_event` covers every `Event` variant server/ can produce today,
including tool-call/question/image attachment and peer-message placement.
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
merges a tool call split across two fetched pages and re-merges a run a
boundary cut through. This matters the moment paging backward through
history is exercised; it is deliberately left rather than rushed, since
it is exactly the kind of boundary logic this project's own "things that
have bitten" section warns reads fine and is wrong at the edges.
**Known gap, and a decision for whoever closes it:** `event_model::Event`
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
A server newer than this build that adds an event type will fail to parse
that line rather than degrading to a placeholder row. Closing this means
deciding how `event_model` itself represents "a shape I don't recognise"
-- a shared-model decision affecting `server/` too, not a `client-core`-only
fix, so it is recorded here rather than silently worked around.
## `config.rs`: `EnrolledServer`
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
parses on the phone -- so any Rust client enrols from the same text a
phone would scan as a QR, with no second format invented for it (RUST.md's
E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is
persisted or under what file permissions -- a phone seals its token in the
Android Keystore, `iris/desktop-app/src/config.rs` writes it to
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
caller-specific (the code rules' "ask for the least you need"). Its only
caller today is `desktop-app`; a future Android build of this crate would
be a second one, not a reason to move the type.
## What is not started at all
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
from the transcript cache or the server, and stitches the two. Needs
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
which exist now, so this is unblocked whenever picked up.
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
colours a `.md` file or fence for the highlighter, but does not build the
block tree (headings, lists, tables, fences as distinct nodes) that a
renderer walks to lay out prose versus code versus a table.
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
AST is Compose rendering plumbing, not something to port as-is; a Rust
UI layer will want its own block parser or a crate for it, decided
alongside the framework choice in RUST.md.
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
it flattens a row into bounded units for a *specific* lazy-list
framework's composition cost, which is a fact about that framework
rather than about the transcript.
## Verifying
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
and `server` in that order (each `cargo test`, forwarding arguments the
same way it always has). From `client-core/` directly: `cargo test`,
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
+27 -41
View File
@@ -1,44 +1,30 @@
# Decisions awaiting review # Decisions taken for Iris to review
Choices made while working autonomously, for Bryan to keep or change. Each Short list of design choices made by the design agent without asking, so
says what was picked and why; the detail is in the design doc it names. they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
Delete an entry once it has been looked at. for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone.
## Subagent views (2026-09-05, `SUBAGENTS.md`) ## 2026-09-05
Made on my own judgement, limited blast radius: - **Touch drag on a transcript row follows Android's own rule**: a vertical
drag pans the list immediately; a stationary press held 500 ms starts a
1. **A subagent is a transcript, not a session.** It has no process, text selection which further dragging extends; a horizontal drag while
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}` something is already selected extends that selection without the wait.
and stored under the session's directory, so deleting the session takes One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
it. Alternative rejected: registering it as a session of its own, which "text layer always wins" or "list always wins" rule because either loses
would give it a card in the main list and a driver that can do nothing. one of the two gestures a reader expects.
2. **Read-only view is the session screen minus its controls**, rather than - **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
a second, simpler transcript screen. Keeps paging, caching, selection holding `transcript-ui`'s screen beside a session list, talking to a real
and rendering in one place. Cost: a `readOnly` mode threaded through `ai-server` through `client-core`. It enrols by pasting the same
`SessionScreen`. `aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
3. **The list only carries a count.** Each session row says how many and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
subagents it has; their titles and statuses are fetched when the card is pinned CA is a path given on the command line, not baked in. Chosen so
expanded. Keeps `GET /sessions` from reading every subagent transcript. the phone and desktop share one enrolment format and no second one is
Consequence: an expanded card's statuses refresh with the list, not live. invented.
4. **Expanded/collapsed is remembered per session on the phone**, not on - **Order of remaining work**: finish the two in-flight pieces above, then
the server. Collapsed by default, per the transcript convention that new the transcript screen's Android integration and the `transcript-bench.sh`
things arrive collapsed. comparison against Compose — the numbers the recommendation still lacks.
5. **Subagents of imported sessions are not shown.** The import path still - **DEFERRED — whether to commit to iris over Masonry for `ai-app`.** Waits
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files on the bench numbers above; RUST.md's recommendation says what the
are not read. Only subagents run while this backend was watching exist. measurements must show.
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.
+19 -48
View File
@@ -14,7 +14,7 @@ AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
## What it is, in one paragraph ## What it is, in one paragraph
A machine's filesystem, seen from the phone through the backend. The explorer A machine's filesystem, seen from the phone through the backend. The explorer
belongs to a **machine** (a machine), not to a session: a session only says 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 where to start. Every operation — list, read, write, create — is one shell
script run through `Transport`, exactly the way the import listing and the 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 usage fetch already work, so the local and the ssh case are one
@@ -25,30 +25,18 @@ message. The phone draws what came back.
### 1. Keyed on the machine, opened from the session ### 1. Keyed on the machine, opened from the session
Routes live under `/machines/{id}/…`, beside `importable`, because a filesystem Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem
is a property of a machine. The session screen's folder button opens the is a property of a machine. The session screen's folder button opens the
explorer with the session's machine and its `cwd`; a session with no `cwd` explorer with the session's setup and its `cwd`; a session with no `cwd`
opens at the machine's home, which the **machine** resolves (`cd` with no 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 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 machines tab explorer knows what a session is, so a later entry point from the setups tab
is one more caller and no new code. is one more caller and no new code.
Rejected: routes under `/sessions/{id}/`. The session would be a detour to Rejected: routes under `/sessions/{id}/`. The session would be a detour to
find the machine, and "browse this machine" from anywhere else would need a find the setup, and "browse this machine" from anywhere else would need a
session to exist first. session to exist first.
The third caller arrived 2026-09-21 and cost no code here, which is the
property this decision was made for: **View raw** in the session settings
dialog opens the explorer on the session's own transcript file
(`fileTarget`), so the record can be read as it is on disk rather than only
as the conversation drawn from it. The session says where the file is
(`transcriptFile` on `GET /sessions/{id}`) because only the backend knows --
and it names **this backend's** machine rather than the session's, which for
a remote session are two different filesystems. Back from the file lands in
the session's own directory, where the log and the process record are.
A transcript past `FILE_LIMIT` is refused the same way any other large file
is, which is the known limit of this as a debugging tool.
### 2. One shell script per operation, over `Transport`, on both transports ### 2. One shell script per operation, over `Transport`, on both transports
Each operation is a small POSIX script handed to `sh -c script sh "$path" …` Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
@@ -80,7 +68,7 @@ Elsewhere the phone picks an **id** and the server resolves which file it
names, so an enrolled token cannot become "read me an arbitrary file". The names, so an enrolled token cannot become "read me an arbitrary file". The
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
Security section in these terms: the token already gates spawning a Security section in these terms: the token already gates spawning a
bypass-permissions agent in any directory on any configured machine, and 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 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. is a shorter path to authority the token already holds, not new authority.
The import rule stands where it is, because there a path was unnecessary and The import rule stands where it is, because there a path was unnecessary and
@@ -94,14 +82,13 @@ writing are fixed scripts; the phone chooses only the path and the bytes.
Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
a relative path would be depends on something the reader cannot see. Every a relative path would be depends on something the reader cannot see. Every
listing answers with `pwd -P` of the directory it listed, so the phone listing answers with `pwd -P` of the directory it listed, so the phone
navigates on a resolved absolute path. The phone also resolves `~` through the navigates on a resolved absolute path — the parent is a string operation on
same route, then shortens that directory and every path beneath it back to that, and a `~` the session was spawned with is shown as what it turned out
tilde notation for display; it never guesses where a local or ssh user's home to be. The phone never resolves `..` itself.
is. The phone never resolves `..` itself.
### 5. A read is capped and typed, and every state it can be in has a word ### 5. A read is capped and typed, and every state it can be in has a word
`GET /machines/{id}/file` answers with one of `text` (content, size, mtime, `GET /setups/{id}/file` answers with one of `text` (content, size, mtime,
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking `FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
at), or the machine's own error. at), or the machine's own error.
@@ -114,7 +101,7 @@ what it is.
### 6. A write is conditional on what the reader saw ### 6. A write is conditional on what the reader saw
`PUT /machines/{id}/file` carries the sha256 the read reported. The script `PUT /setups/{id}/file` carries the sha256 the read reported. The script
compares it against the file as it is now and exits distinctly if it differs; compares it against the file as it is now and exits distinctly if it differs;
the server answers **409**. Agents edit files while people read them; this is the server answers **409**. Agents edit files while people read them; this is
the common case, not the exotic one, and silently overwriting an agent's edit the common case, not the exotic one, and silently overwriting an agent's edit
@@ -135,9 +122,9 @@ precondition is fresh without a second read.
### 7. Create refuses to overwrite ### 7. Create refuses to overwrite
`POST /machines/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a `POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
name that exists fails with the shell's own message rather than truncating name that exists fails with the shell's own message rather than truncating
somebody's file; `POST /machines/{id}/dir` is `mkdir --` with the same somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same
property. The modal names one thing in the current directory and has a switch 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 for "directory"; a created file opens straight into edit mode, because an
empty file is not something to look at. empty file is not something to look at.
@@ -221,21 +208,17 @@ absence the signal. Back with unsaved changes asks, and says the edits will
be lost. The explorer draws over the session, which deliberately has no be lost. The explorer draws over the session, which deliberately has no
`imePadding`, so the explorer's own box adds it. `imePadding`, so the explorer's own box adds it.
### 10. The explorer draws over the session, and back follows what is open ### 10. The explorer draws over the session, and back closes it first
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the `Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
`FilesScreen` is composed **on top of** the session in the same `Box`, and `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 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 scroll position and draft stay where they were, and returning from a file
costs nothing. From an open file, both the header's back button and Android back costs nothing. Back — the button and the platform gesture — clears `files`
return to its containing directory. From a directory, the header's back button when set and goes to the list otherwise. Inside the explorer the same back
clears `files` and returns to the session. Android back instead walks toward the steps one level: editor → viewer (with the unsaved question) → listing →
session's project directory: upward to the common ancestor, then down one path parent directory, and only from the starting directory does it close. "Back
segment per press, and at the project it returns to the session. This makes returns; it does not exit."
Back from `/etc` visibly travel through `/`, `/home`, and onward to a project
under `~/repos`, rather than leading away from it. The `..` row remains explicit
parent navigation. An editor with unsaved changes asks before either route
discards them. "Back returns; it does not exit."
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a 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 leaf screen goes to Main today, and a session disposed and re-created on each
@@ -284,18 +267,6 @@ The speedometer went; the report is a "Copy render timings" row in
already are. **Moving it is where the no-coordinate-taps rule got enforced** already are. **Moving it is where the no-coordinate-taps rule got enforced**
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI". (Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
### 14. File links in a session open in the explorer
A markdown destination that is an absolute path or a local `file:` URI opens that document in the
session's explorer, on the session's machine. A trailing editor line and optional column are removed;
the viewer opens the file but does not yet scroll to a line. Web links, relative links and `file:`
URIs naming another host keep their ordinary external behaviour. The distinction is deliberately
narrow: a relative link might be a web reference, and the phone must not silently reinterpret it as
a path on another machine.
The markdown link handler is provided around the session rather than taught about machines. That
keeps the renderer reusable and makes the explorer's existing machine target the one navigation path.
## HTTP surface ## HTTP surface
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields` In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
+277
View File
@@ -0,0 +1,277 @@
# iris: notable public API changes
For Iris to read on her own time. Each entry is a change to iris's public
surface that a widget author or app author would notice: a trait method
added, removed or re-shaped; a type that callers construct differently; a
capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-05: `transcript_ui::build_tree` (RUST.md's E4)
`transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`)
as its last step, which is right for a window that *is* the transcript
screen (the winit example, an eventual Android cdylib) and wrong for the
desktop app, which puts a session list beside it. `build_tree` is `build`
minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead
of just `TranscriptScreen`, and the caller decides where the tree goes —
into `ui_state.set_root`, or into a `WidgetPtr` alongside something else
(`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling
`build_tree` and doing the `set_root` itself, so existing callers are
unaffected.
```rust
// before, and still available, for a caller that wants to *be* the window:
let screen = transcript_ui::build(rsc, &mut ui_state, rows);
// new, for a caller embedding the screen beside something else:
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
some_widget_ptr(rsc).set(tree);
```
## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
New public type, `iris::sense::DragArbiter`. Why: a widget author who
registers both a list-level pan and a row-level drag-to-select on the same
touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s
`run_sensors` always gives the innermost layer first refusal, so the inner
one wins every frame it is pressed, not just the frame the press started
(this is exactly what left transcript-ui's touch-drag panning unreachable
until now). `DragArbiter` is one small state machine, one instance per
gesture surface (a whole list, not per row), that a caller drives with its
own `press_start`/`update`/`release` calls and a caller-supplied `Instant`
(so it is unit-testable without a real clock or a render harness). It
decides the way Android itself does: an ordinary vertical drag pans
immediately; a stationary press held `LONG_PRESS` (500ms) starts a
selection, which any further drag then extends; a horizontal drag while
something is already selected extends it immediately, skipping the wait.
```rust
// One per list, held alongside whatever state coordinates the rows:
let mut arbiter = DragArbiter::new();
// On press-down:
arbiter.press_start(pos, Instant::now(), already_selected);
// Every frame the button/finger stays down:
match arbiter.update(pos, Instant::now()) {
DragOutcome::Pan(dy) => list.scroll(-dy),
DragOutcome::SelectStart => selection.begin(...),
DragOutcome::SelectExtend => selection.extend(...),
DragOutcome::Undecided => {}
}
// On release:
arbiter.release();
```
`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is
the reference caller: every row's `CursorSense::click_or_drag() |
CursorSense::unclick()` handler routes through one `Selection`-owned
arbiter instead of calling `begin`/`extend` directly, so a drag that starts
on a row's own rendered text now pans the list correctly instead of
always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s
`drag_arbiter_tests` module.
## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5)
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
family, ...) for its whole string, applied via `push_default` into parley's
ranged builder. `SpanStyle` is a second, optional layer: a byte range plus
whichever of colour/family/font size/bold/italic/underline it overrides,
pushed with parley's own `push(property, range)` instead. Why: a transcript
row's markdown (a heading, **bold**, `inline code`, a link) all inside one
wrapped paragraph needs each to carry its own look while the paragraph
still wraps and selects as a single buffer — the thing `masonry`'s
`TextArea` cannot do (`StyleSet` is one style for the whole editor,
`text_area.rs:43-44`'s `// TODO: RichTextInput`), and the reason this
existed at all.
```rust
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
wtext(text)
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
.editable(EditMode::MultiLine)
.add(rsc);
```
Two things a widget author should know before reaching for it:
- **Call `.spans()` before or after `.editable()`, both work** — the field
lives on `TextBuilder` itself, not either output type, and both
`TextOutput::run` and `TextEditOutput::run` apply it to the buffer via
`TextBuffer::set_spans`. **These two call sites are a pair**: adding a
third `TextBuilderOutput` impl without also calling `set_spans` there
reproduces the exact bug this box shipped once already (spans silently
dropped for `TextEdit`, found only by screenshotting, not by any test —
`markdown.rs`'s own unit tests check string/range logic, which is
correct in isolation and proves nothing about whether the render path
ever sees it).
- **Colour is now per-glyph, not per-buffer.** `PlacedGlyph` gained a
`color: UiColor` field (from parley's own per-run `Style::brush`), and
`Painter::glyphs` draws each glyph in its own colour instead of
`RenderedText::color` uniformly. `RenderedText::color` still exists (the
buffer's *base* colour, for a caller that wants it as a whole, e.g. to
tint a cursor) but no longer drives what a glyph actually renders as.
## 2026-09-05: accessibility names via AccessKit (RUST.md's I4)
`.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree)
is now load-bearing: it's the one thing that puts a widget in the AccessKit
tree `iris_core::ui::access::AccessTree` builds and both backends push
out. A widget author who wants a control to be findable by name (and
tappable by name, through `ui-trace`/a real screen reader) calls `.label()`
on it; nothing else is required, and a widget nobody labels is invisible
to this system at zero cost, not just zero UI.
```rust
let button = rect(Color::LIME)
.on(CursorSense::click(), move |_, rsc| { ... })
.label("Add task"); // now findable by uiautomator/AccessKit as "Add task"
```
Two new things a widget author might touch directly:
- **`Widget::access_role(&self) -> accesskit::Role`**, default `Unknown`.
Override it if your widget has a real platform equivalent —
`TextEdit` now returns `TextInput`/`MultilineTextInput` by `EditMode`.
Only consulted for a widget that also has a `.label()`; an unlabelled
widget's `access_role` is never called.
- **`Widgets::named() -> impl Iterator<Item = WidgetId>`** — every widget
with an explicit label, for anything else that wants to walk the same
set `AccessTree` does.
Nothing about `Painter`, `draw`, or the layout/move machinery changed —
this sits entirely beside them, reading `resolved_region`'s output rather
than participating in producing it.
## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3)
A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its
module doc first), for the transcript's kind of screen: variable-height
rows, keyed by a `u64`, composed only while visible, moved rather than
re-laid-out on scroll, a scroll anchor that survives a row inserted above
it, "more" sentinels at each end, and "hold the edge nearest the tap" when
a row's height changes (`note_tap`, resolved in the layout pass).
```rust
let mut list = List::new(Axis::Y);
list.push_back(ListRow::new(key, row_widget)); // O(1)
list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected
list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge
list.note_tap(viewport_y); // before mutating a row's height
let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible
```
Built entirely out of existing primitives (`Painter::widget`/`widget_within`/
`reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) --
no new mechanism was added to the render core for it. One correctness
lesson worth reading even for other widgets: a row that fills whatever
region it is offered (`Rect`, `is_size_independent`) cannot be measured at
a throwaway oversized region and then merely `reposition`ed into place --
`reposition` only ever writes an offset, never a size, so the oversized
primitive stays oversized. `List` fixes this by caching each row's real
height once measured and placing an already-known row directly at its
exact box; see `list.rs`'s `place` for the full reasoning and
`a_fill_shaped_background_is_not_left_oversized` for the regression test.
## 2026-09-05: a second backend (android-view), and what moved to make room for it
RUST.md's I2. Three changes a widget or app author would notice, all in
service of the same thing: `default` (winit) and the new `android`
(android-view) backends sharing what does not depend on windowing.
- **`Selector`/`Selectable`'s bound changed from `Rsc::State:
HasDefaultUiState` to `Rsc::State: FocusHost`** (new trait, `attr.rs`).
`HasDefaultUiState` still exists and still works — `default/attr.rs` now
implements `FocusHost` for anything that has it — so a winit app's
existing code is unaffected. An Android app implements `FocusHost` via
`HasAndroidUiState` instead. Affects only an app that referenced
`HasDefaultUiState` directly at a `Selectable`/`Selector` call site
rather than through `.attr::<Selectable>(())`, which nothing in-tree
does.
- **`Tasks::init` takes `Arc<dyn RequestRedraw>` instead of
`Arc<winit::window::Window>`.** `RequestRedraw` (`task.rs`) is one method,
`fn request_redraw(&self)`; `winit::window::Window` implements it
(`default/render.rs`), so `Tasks::init(window)` at a call site is
unchanged by inference. Only matters if something constructed a `Tasks`
directly rather than through `DefaultRsc`/`AndroidRsc`.
- **`TextEdit::apply_event`/`TextInputResult` are `#[cfg(not(target_os =
"android"))]`** — they take a `winit::event::KeyEvent`, which does not
exist on Android; `android/input.rs` drives the same primitives
(`backspace`/`delete`/`motion`/`insert`, all still unconditional) from
`ndk::event::Keycode` directly instead. New unconditional getters on the
way: `TextEdit::text()`/`selection_range()`/`caret()`, and
`TextEditCtx::delete_byte_range`/`set_cursor_byte` — the primitives
`android/ime.rs`'s `InputConnection` bridge needed and that were not
previously exposed publicly.
## 2026-09-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone
A widget used to implement three methods (`draw`, `desired_width`,
`desired_height`); it now implements one, `fn draw(&mut self, painter: &mut
Painter) -> Size`, which draws into `painter.region()` and returns how much
of it was used. Why: the two extra methods routinely re-simulated what
`draw` was about to do anyway (`Span::desired_ortho` copied its own draw
loop to get cross-axis sizing right) — one visit per widget per frame
instead of up to three. A container that needs a child's size before
placing it (alignment, centering) draws the child once at a provisional
region, reads the returned `Size`, and calls the new `Painter::reposition`
to move it into its final spot — an O(1) offset write, not a second draw. A
widget whose drawn output never depends on the size it's given (a
fixed-size `Rect`, a decoded `Image`) overrides the new `fn
is_size_independent(&self) -> bool { false }` to `true`, which skips
redrawing it when only its offered region changes shape.
```rust
// before
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
// after
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
```
`SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
design, the move-offset mechanism this shipped alongside, and the file
list.
## 2026-09-04: texture pipeline rebuilt off the binding array
`Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all
changed shape. Why: the old pipeline bound every texture ever drawn in one
`binding_array<texture_2d<f32>>` and asked every device, unconditionally,
for `VK_EXT_descriptor_indexing` — a real share of Android GPUs lack it,
and it failed outright on the Android emulator's software Vulkan. See
TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04".
- **`UiRenderNode::new` drops its `limits: UiLimits` parameter, and
`UiLimits` is gone.** Before: `UiRenderNode::new(&device, &queue,
&config, UiLimits::default())`. After: `UiRenderNode::new(&device,
&queue, &config)`. Nothing replaces it — there are no more
binding-array limits to size.
- **`src/default/render.rs`'s device request asks for no features and no
binding-array limits.** Before: `required_features:
Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`
plus two `max_binding_array_*` limits. After: `Features::empty()` (the
`DeviceDescriptor` default) and only `max_buffer_size` set, which was
never about the binding array.
- **`TextureHandle` has no `primitive()` method any more**; a caller
outside `iris` shouldn't have been calling it (it fed the old renderer's
internals), but if something did: use `image_index()` for a standalone
image's bind-group index. There is no equivalent for a page — a page has
no bind group of its own now, see below.
- **`GlyphPrimitive` has no public constructor from a struct literal.**
Before: `GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color,
flags }`. After: `GlyphPrimitive::new(uv_min, uv_max, layer, color,
flags)` — one `layer` (the shared atlas array's layer) instead of a
`view_idx`/`sampler_idx` pair, since a page is now a layer of one array
texture rather than its own bound texture.
- **A widget author drawing images is unaffected**: `Painter::texture`/
`texture_at`/`texture_within` and `Textures::add` keep their signatures.
What changed underneath is that each standalone image now gets its own
`wgpu::BindGroup` and draw call instead of a slot in the shared array —
invisible from the widget API, visible only in `UiRenderNode`'s internals
and in `iris`'s device requirements.
+254
View File
@@ -0,0 +1,254 @@
# iris: known problems and things still to build
Iris's own list for the library, recorded 2026-09-04 in her words where it
matters, so the agents working through RUST.md pick these up in a sensible
order rather than rediscovering them. Each item says where it sits in the
order and what "done" looks like. Tick and date them in place.
## Fix
- [x] **Input does not fall through by input type (2026-09-04).**
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
stop checking lower layers" from mere hover — a widget registered for
nothing but `click()` blocked a `Scroll` meant for whatever was behind
it, since "the cursor is over this widget" and "this widget handled the
event" were the same check. Fixed by judging consumption per input
kind: with no button transition and no scroll happening this frame
("momentary" activity), the topmost hovered widget still wins, same as
before; when something momentary *is* happening, only a widget whose
registered senses actually include a matching non-hover one (checked
via a new `TypeEventManager::registered`, which lists what a widget
registered without running anything) consumes it, so a widget with only
`Hovering`/click handlers can no longer block a scroll from reaching a
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
`Stack` with a plain `HasEvents` impl (no GPU or window) and checks both
directions: a scroll over the button reaches the list, and a real click
still reaches the button — confirmed to fail on the pre-fix code and
pass after.
- [x] **Appending one image to an already-loaded list rebuilds every other
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
triggered `rebuild_image_bind_groups` — a loop over *every live
standalone image*, rebuilding its `BindGroup` — whenever the shared
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
every widget gets one on first draw) could be exactly what grows that
buffer. So one new message with one new image, appended to a transcript
that already has N images loaded, did not cost O(1): it cost one
`create_image` for the new image plus one `make_image_bind_group` per
*existing* image, because the new widget's own move slot pushed the
arena past its capacity. Measured directly in
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
already-settled ones reported **1,001** bind-group creates for that one
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
bind group (group 2) in the first place — the group also holds that
image's own texture view, which is the only thing that is genuinely
per-image, so a buffer shared by *everything* forced a rebuild of
*every* group the moment it moved. Gave masks/move_offsets their own
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
once per draw call, instead of duplicating them into every per-image
group. `GpuTextures` and its image bind groups now know nothing about
either buffer — `rebuild_image_bind_groups` is called only from
`grow_array` (the atlas array texture growing, which genuinely does
change what every image's own bind group must reference) — so a
masks/move_offsets resize now touches exactly one bind group, ever,
regardless of how many images are live. Numbers after the fix, same
benchmark and command:
./run-bench.sh images
frame=1 bind_group_creates=1000 (cold load, unchanged)
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1 (was 1001)
frame=6 bind_group_creates=0
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
confirming the bind-group restructuring changed nothing about what is
drawn.
- [x] **Bind-group creation takes two frames to reach the steady state, not
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
loading 1,000 images cold used to report 1,000 creates on frame 1
(expected — `create_image`, one per new image) *and again* 1,000 on
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
firing a second time for the same masks/move-offsets buffer-growth
reason as the item above, confirming the guess recorded here — the two
were exactly the same root cause measured two different ways. Frame 2
now reports 0 (see the numbers above); not a separate fix.
## Build
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
`benches/` or a script under `iris/`, never in `cargo test`). The
scenario that matters most is a **message list** — chat apps and this
app's transcript alike — stressed with many messages and many images.
One case in particular: **resizing an input box** (typing enough text to
grow it) that pushes a long list of messages above it must stay very
fast and recalculate almost nothing — a move of everything above, not a
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
benchmark is what proves it. Done when the numbers are in this file with
the command, and the input-box case reports draws re-run, not just frame
time.
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
most of this runs as an ordinary binary — the same property
`layout_tests.rs` relies on):
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
the file's own header for why (short version — every scenario here
reduces to a *count* `UiRenderState::take_counters` already produces,
which criterion's statistical machinery adds nothing to and which a
new dependency is not worth pulling in for). Covers (a) first-frame
cost of a message list of N wrapped-text rows (one in 20 also carrying
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
of scrolling that list, 200 ticks; (c) the input-box case — a
fixed-height field at the bottom of the screen growing by a line 40
times, with the message list above it filling the rest of the screen.
Run: `cd iris && cargo bench --bench message_list` (always release —
`cargo bench` builds the `bench` profile, which is optimized).
- `iris/examples/bench_images.rs` — needs a real device, so it runs
through `iris/run-headless.sh bench_images`, printing
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
image rows, checked both cold (does bind-group creation reach zero
once loaded) and after appending one more image once settled (does
*that* stay cheap) — the second question is what actually matters for
a live transcript and is what turned up the two Fix items above.
- `iris/run-bench.sh [list|images]` runs either or both and is what to
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
chain, or `GpuTextures`.
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
cd iris && cargo bench --bench message_list
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
(b) scroll, N=100/1000/10000, 200 ticks each:
draws=200 rewrites=0 moves=200 (identical at every N)
per-tick average: 0.0002ms (identical at every N)
(c) input grows 40 lines, N=100/1000/10000 rows above it:
draws=320 rewrites=40 moves=160 (identical at every N)
per-line average: 0.0012-0.0013ms (identical at every N)
cd iris && ./run-bench.sh images (2026-09-05, before the fix)
frame=1 bind_group_creates=1000 (cold load)
frame=2 bind_group_creates=1000 (see Fix item above)
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1001 (see Fix item above)
frame=6 bind_group_creates=0
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
frame=2 bind_group_creates=0
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
frame=6 bind_group_creates=0
**Reading it**: (a) is real, necessary work — shaping and laying out N
never-before-seen text rows — and scales with N as it must, ~10x cost
per 10x N. (b) and (c) are the pass conditions that matter: both are
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
move chain holds for both scrolling and for a growing input box pushing
the message list — draws/moves per tick or per line do not grow with
list size, and the per-operation cost (a fraction of a microsecond) is
nowhere near a frame budget. (d)'s cold-load and steady-state halves
behave as designed; its *append* half did not, until the fix above moved
masks/move_offsets out of the per-image bind group — now flat at O(1)
the same way (b) and (c) are.
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
left, each recorded at the point in the code it would go rather than
silently dropped. See RUST.md's I5 box for the full account of what
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
composer).**
- [ ] **Android integration for this screen does not exist yet.** No
cdylib/Gradle shell the way `iris-android-app` wraps `tabs-ui` (I2),
so `transcript-bench.sh`'s render-number pass condition against the
Compose baseline cannot be run. Needs: real `client-core::ApiClient`/
`event_stream::follow_session_events` wiring against
`app/ui-sandbox.sh --delay` (this crate deliberately fetches nothing
itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle
module, then the bench script pointed at it.
- [x] **Touch-drag panning over a row's own rendered text — done,
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
registration won `core/src/sense.rs::run_sensors`'s per-layer
arbitration on every frame it was pressed, not just the frame the
press started, so a list pan gesture registered on `List` itself never
got a turn while a row was under the finger. Fixed with
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
machine per list deciding pan vs. select the way Android does (a
vertical drag pans immediately; a stationary press held `LONG_PRESS`
(500ms) starts a selection which further drag extends; a horizontal
drag while something is already selected extends immediately) —
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
every row's drag now routes through. 8 new unit tests
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
clean; `run-headless.sh` screenshot byte-identical to before the
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
- [ ] **Row-level accessibility names.** The composer carries
`.label("Message")`; transcript rows do not carry a `.label()` of
their own yet, so `Widgets::named()` (I4) does not include them —
`row.rs`'s `build_text_row` is where one would go, keyed to something
stable per row (its sender + a short excerpt, matching what a screen
reader announcing a chat message would say).
- [ ] **A tappable link and a background chip behind inline code.**
Both need per-range glyph geometry that `TextEditCtx` does not expose
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
private) — see `markdown.rs`'s module doc for the exact shape the fix
would take (the same primitive `TextEdit::draw`'s own selection
highlight already uses internally,
`iris/src/widget/text/edit.rs:99`).
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
is selected in full (`select_all`) the moment the drag leaves it,
rather than "from the click point to whichever edge points away from
the drag" — needs the same private `layout()` access as the item
above. `selection.rs`'s module doc has the exact reasoning.
- [ ] **No syntax highlighting inside a fenced code block.**
`client_core::highlight` exists (built for the file explorer) and
could feed per-token `SpanStyle`s into a code block's span; wiring it
in was not attempted this pass.
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
by something *and also* applies mask B — a mask can reference a parent
mask, the way the move chain references a parent offset. Today masks
are independent regions. Design it beside the move chain (same shape:
a parent index and a bounded walk in the shader); do it when a real
widget needs it, not before.
- [ ] **Positions as a single float per scroll.** Iris raised, and half
rejected, letting a scroll update one float rather than positions:
input handling cares about most elements in a list, so absolute
positions must be computed on the CPU anyway. LAYOUT.md's design
already lands here (GPU walks the chain, CPU resolves on demand for
hit tests). Keep the CPU resolution lazy and per query; do not
materialise every row's absolute position per frame.
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
**modular — a piece of the library rather than a core part forced into
everything, the same way input is**. Whatever the mechanism, a widget
that does not animate must pay nothing and import nothing for it.
## Reconsider
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
to compose a widget from others (a button is the main case). With
sizing folded into `draw`, composing may be easy enough that `View` is
redundant. Decide after the layout change lands, by writing a button
both ways and keeping the one that is shorter to explain; delete the
other rather than keeping two ways.
+897
View File
@@ -0,0 +1,897 @@
# iris: one `draw` that reports a size
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
any design or code so that it survives a cleared session. **Status: implemented
2026-09-04, against every pass condition in §8** (measured, not assumed — see
that section). Every widget listed in §7 was migrated in one change; none
kept `desired_width`/`desired_height`. Five points needed correction or
refinement beyond what this file originally specified — see "Deviations
found during implementation" below, added right before "For IRIS.md" — read
that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the
move-slot lifecycle in `render_state.rs`, since each of those five is a real
bug this file's first draft would have reproduced if implemented literally.
## What Iris asked for
> I don't like that widgets need both a draw and size functions. I'd much
> rather them have a single draw that reports a size, and if it needs to be
> moved then that can be done after the fact efficiently, or resized just
> done after as well. This should be done efficiently like everything else
> tries to do right now.
She added, a few minutes later: "single draw is not a requirement. It
just seems more efficient from what I've heard. Feel free to override any
decision I've made if you can find a genuinely better & still clean
alternative." So the single-draw model is the default to design against,
and the design below may reject it, but only with a written comparison
showing the alternative does less work per frame and is no harder to use.
Standing constraints from RUST.md still apply: no DSL, plain Rust, do as
little processing as possible per frame, but the model must cover every
layout need a real app has (the transcript's virtualised list, wrapped
text whose height depends on width, rows and columns that size to their
children, overlays, masks).
## What exists today
`Widget` (`iris/core/src/widget/mod.rs`) has three methods: `draw(&mut
self, &mut Painter)`, `desired_width(&mut self, &mut SizeCtx) -> Len` and
`desired_height`. A parent asks `SizeCtx::width/height` for a child, which
is memoised per widget id and axis in `Cache.size` keyed on the outer
size, then places the child with `Painter::widget_within(region)`. So a
child is visited twice (sized, then drawn), every widget implements sizing
twice (one per axis), and a widget whose size depends on what it draws
(wrapped text, a laid-out paragraph) does the layout in the size pass and
again in the draw pass unless it caches by hand.
Primitives are already positioned by `UiRegion` values whose scalars have
a `rel` and an `abs` part, resolved against the window in the vertex
shader (`core/src/render/shader.wgsl`), and `Primitives::region_mut`
exists to rewrite one instance's region in place. That is the mechanism a
"move after the fact" can build on.
## What the design must answer
1. **Parent-before-child ordering.** A row has to know each child's width
to place the next one, but under "one draw" the child's size only
exists after it has drawn. The answer is meant to be: the child draws
at a provisional origin, reports its size, and the parent *moves* it.
The move must be O(1) per moved subtree, not O(primitives in the
subtree). One way: every instance carries an index into a small
per-widget offset buffer, so moving a widget writes one entry and the
vertex shader adds it. Other ways may be better; the design should say
what was considered.
2. **Move vs resize are different costs and must be kept apart.** A move
never re-runs `draw`. A resize re-runs `draw` for exactly the widgets
whose size input changed, and a widget whose output does not depend on
its size (an icon, a fixed rect) must be able to say so and be skipped.
3. **Size-dependent content.** Wrapped text is the hard case: its height
is a function of its width. A single `draw` receives the available
size (what `SizeCtx.outer` is today) and reports what it used, so the
two-pass "measure then draw" collapses into one for the common case.
The design must say what happens when a parent wants the child's
height *before* deciding the width it will offer (rare; say whether it
is supported, or is done by drawing twice as an explicit, opt-in cost).
4. **Caching.** Today's `Cache.size` memoises by (id, axis, outer). The
replacement should memoise the whole draw result by (id, available
size) so that an unchanged subtree costs nothing on the next frame,
which is what makes a virtualised list cheap.
5. **Everything currently written against `desired_width`/`desired_height`
moves over in one change**, per the code rules: two names for one
concept is not an intermediate state to leave behind. The widgets are
in `iris/src/widget/` (`ptr`, `mask`, `image`, `rect`, `trait_fns`, and
whatever else is there when the change is made).
## Order relative to the texture work
TEXTURES.md's redesign touches the render core (shader, `GpuTextures`,
`Primitives`, `Painter`'s texture calls). This change touches the widget
trait, `SizeCtx`, `Cache`, `Painter`'s widget calls, and any offset
mechanism the vertex shader needs. They overlap in `Painter` and the
shader, so they are done **in sequence, textures first**, and the layout
design here is written (not implemented) while the texture work is in
progress, then implemented on top of it.
## Design
### 1. The new `Widget` trait
```rust
pub trait Widget: Any {
/// Draw within `painter.region()` (the space the parent offered) and
/// report how much of it was actually used, per axis.
fn draw(&mut self, painter: &mut Painter) -> Size;
/// True if `draw`'s output (both the primitives it writes and the
/// `Size` it returns) is the same for any `painter.region()` of the
/// same *content* -- an icon, a fixed-size rect, an already-decoded
/// image at its natural size. Default `false` (redraw on any change to
/// the offered region) because assuming independence wrongly produces
/// a stale draw; a widget must opt in.
fn is_size_independent(&self) -> bool {
false
}
}
```
No `available` parameter: `Painter` already carries the region the parent
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
size (`output_size()`, `:152`). Passing it again would be the same value
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
deprecated, not kept as a fallback — because a widget that implements both
`draw` and `desired_*` for the same thing is exactly the "two names for one
concept" the code rules call out, and it is what today's `Span::desired_ortho`
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
own comment: "this literally copies draw so that the lengths are correctly
set in the context, which makes this slow and not cool." Folding sizing into
`draw` deletes that duplicate simulation, not just moves it.
**No single-draw alternative was found that does less work per frame.** The
two-method trait was checked against three properties a real screen needs —
a row placing children in sequence, a widget centering on its own content,
and wrapped text — and in every one, `draw` already has to visit the child
to get a size that is *this specific one's* answer, which today's
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
the same layout the draw pass will do again. So the two-method trait is not
"measure once, draw once" in the general case; it is "measure once per axis,
then draw once," i.e. up to three visits per widget per frame, against one
under the design here. The single-draw model is therefore adopted as
proposed, not merely accepted as a preference.
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
**What exists today, and why it is not O(1).** `UiRenderState::mov`
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
its *size* but changes *position* (`draw_inner`, `:85-100`:
`active.region.size() == region.size()` after excluding the exact-match
case). It rewrites every primitive's `region` field via
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
widget's own primitives, then recurses into every child — O(primitives in
the subtree). Both call sites that trigger it today, `Scroll::draw`
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
re-touches every glyph in every visible row, every frame of the drag, and
I3's target is 800 rows on screen.
**Recommendation: a per-widget offset slot forming a parent-linked chain,
resolved in the vertex shader.**
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
add nothing more"). A pure abs-pixel translation, not a general
`UiRegion` remap — sufficient for every existing call site (above).
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
kind of per-instance handle.
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
assigned **when the widget is first drawn** (`draw_inner`, beside
`active.insert`), with `parent` = the drawing widget's parent's slot.
`Painter` threads a `move_slot` field down exactly as it already threads
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
correct from its first frame — nothing is ever retrofitted onto an
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
- `mov(id, delta)` becomes: look up `id`'s slot, write
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
recursion, since descendants already reference this slot transitively.
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
pixels (after `:106`, before the clip-space divide at `:113`), walks
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
small constant, e.g. 16, with a CPU-side debug assertion that no chain
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
paid every frame regardless of whether anything moved — negligible next
to the per-fragment texture sampling TEXTURES.md already measures this
GPU as not bound by.
**Why the chain, not the flatter thing first proposed.** Iris's own
phrasing — "every instance carries an index into a small per-widget offset
buffer" — describes a flat table: one slot per subtree *declared* movable,
no parent link. It breaks the moment two such subtrees nest — a row inside
a scrolling list, itself later given its own animated offset (a
swipe-to-delete mid-scroll) — because the row's primitives would have to
pick one slot and lose the other's contribution. The chain costs one extra
field and a bounded shader loop in exchange for no such gap, and since
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
no more at the common depth of one than the flat version would.
**Against `region_mut` as the steady-state mechanism**: rejected for being
O(primitives in the subtree) — the cost this section removes — but kept
for a resize that changes a region's `rel` component (a genuine reflow,
§3) and for a size-independent widget's resize (§3), where the content's
shape doesn't change and one field write already suffices.
### 2b. Two more readers of "where is this widget," and masks
Moving the offset into the vertex shader means `ActiveData.region` is no
longer the on-screen truth once a widget has been moved — it is where the
widget was *drawn*, before any `move_offsets` delta. Two things read it as
if it still were, and both must move to a resolved query or they silently
answer with the pre-move position: a click landing on a scrolled row would
be routed to whatever used to be there, with nothing on screen to say so —
exactly the "wrong answer that looks like a right one" case the code rules
single out.
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
does the actual pointer routing, and line 170 is the read in question:
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
immediately turned into pixels and tested against the cursor at `:171-172`.
Under this design that region must be resolved through the same chain the
GPU walks before it means anything. Add to `UiRenderState`:
```rust
/// `active[id].region`, corrected by every `move_offsets` delta between
/// `id` and the root — the CPU-side twin of the vertex shader's chain
/// walk, over the same arena, so the two cannot disagree about where a
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
/// `move_offsets`, bounded by the same constant the shader loop uses
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
/// const across the language boundary).
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
```
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
coordinate query already used outside hit-testing
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
another's on-screen box), is reimplemented to call `resolved_region(id)`
before `.to_px(...)` instead of reading `.region` directly — one change
covers both call sites listed there. `sense.rs:170` changes to
`let shape = self.resolved_region(*id);`. Both are required the moment §2
lands, not an optional follow-up: an unmoved widget's chain is empty and
`resolved_region` costs one arena read to find that out, so there is no
version of this design where skipping the fix is a legitimate
optimization — it is a correctness gap, not a performance one.
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
painter's *current* region into a `Mask` pushed onto
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
widget that called `set_mask` — `Masked::draw`,
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
is itself later moved, its clip rectangle stays where it was drawn while
its content moves out from under it: a visibly wrong clip, immediately on
screen, not a latency question.
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
written from `Painter::set_mask` as `self.move_slot` — the identical slot
the mask-owning widget's own primitives already get (§2), not a second
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
currently computes the mask's `top_left`/`bot_right` inline from
`masks[in.mask_idx]`; that computation is extended to walk the same
move-offset chain §2 added, via one shared function —
```wgsl
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
```
— called from `vs_main` for a primitive's own corners and from `fs_main`
for its mask's corners, so the walk is written once and the two stages
cannot drift apart (the sibling-rule from the code rules: one loop, not a
hand-copied second one in the other shader stage).
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
primitive's mask is frequently owned by a *different* widget than the
primitive itself — often several levels up a subtree, with its own,
independent move slot — so a primitive's resolved offset and its mask's
resolved offset are two different chain sums, both needed, and only the
fragment shader has both `in.move_idx` (this fragment's own chain) and
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
in hand per-fragment. Resolving mask regions on the CPU at move time would
mean, for every `mov()` call, walking forward to every mask instance the
moved widget's slot could affect and rewriting its raw region — exactly
the O(subtree) cost §2 exists to remove, just moved from primitives to
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
frame (`:148`); one more arena read to resolve its chain costs nothing
extra in kind.
**The scroll-container case, checked rather than assumed.** A masked,
scrollable region is built as a `Masked` wrapping a `Scroll`
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
viewport border is drawn (and `set_mask` called) by `Masked`, which is
never itself the target of `mov()`; only `Scroll`'s inner content is,
every frame the user drags. Because each widget's move slot is its own
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
its own, stationary slot, while the scrolled content underneath references
a separate, deeper slot whose `parent` chain passes through — but does not
write to — the viewport's slot. Moving the content therefore never touches
the mask's resolved position, and the mask staying still while its content
slides past it is what this design already produces with no special case,
not an extra rule that had to be added for it.
### 3. Resize scope
A resize is "the region a widget's parent offers it changes such that the
widget's draw might produce different output" — as opposed to a move, which
by construction cannot (§2 is scoped to pure translation). Two independent
narrowings apply, and both are real, measured properties of the code as it
stands rather than new machinery:
**(a) A window resize does not, by itself, require touching most widgets.**
`shader.wgsl:105-106` recomputes every primitive's pixel position from
`window.dim` and the primitive's stored `rel`/`abs` pair *every frame,
already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no
call to `px_size()`, `output_size()`, or anything else that reads a
concrete pixel count) is therefore already correct after a resize with zero
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
(`render_state.rs:229-231`) currently ignores this and redraws the entire
tree on every `resized`, which was the safe default while sizing and
drawing were two passes; it should be narrowed to only the widgets that
*do* read a concrete pixel value. Track this the same way `needs_redraw`
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
pixel-dependent by calling through `Painter` methods that read
`output_size`/`px_size` (both already funnel through `Painter`, so the
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
walks only that set instead of unconditionally setting `resized = true`
for a full `redraw_all`. This turns "every resize redraws everything" into
"every resize redraws what depends on pixels" — a real behavior change
beyond what was asked, so verify it against the I0b `pre_present_notify`
resize regression (that fix depended on `redraw_all`'s completeness)
before narrowing this.
**(b) A widget's `available` (its parent's offered region) can change
without the widget's *content* changing — this is what
`is_size_independent` (§1) answers.** When a container's own layout shifts
(a sibling grew or shrank, changing this widget's offered box), a widget
that returns `true` from `is_size_independent` is not redrawn: its
primitives are unaffected by size, only by placement, so the parent
either (i) issues a move (§2) if only position changed, or (ii) rewrites
the primitive's `region` fields directly via `region_mut` if the box
changed shape too (still O(primitives owned directly by this widget, not
its subtree, since a size-independent widget by definition has no
size-dependent descendants worth distinguishing — in practice this is
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
`false` (the default) is redrawn in full whenever `available` changes,
which is correct always, just not free.
**Ancestor propagation** (a resized child changing its own reported size,
requiring its parent to re-lay-out) is unchanged in spirit from today's
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
ancestors whose cached size differs from the new one and stops as soon as
a size is unchanged (`:274-286`). That loop moves from consulting
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
### 4. Wrapped text, and "needs child height before choosing width"
**Wrapped text is not a special case any more; it already reads as one
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
wrap width, shapes once, and memoizes the shaped layout keyed on that width
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
the same width is a hash-map-style cache hit, not a re-shape. Under the new
trait this collapses `Text::draw`/`desired_width`/`desired_height`
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
`self.view.draw(painter)` once, which internally still calls `render`
once, hits its own cache, and returns the size it already computed. No
new caching is needed here; the two now-redundant call sites
(`desired_width`/`desired_height` each separately calling `render`) simply
disappear, which is a second `render` avoided per frame per text widget
that is being measured by a parent.
**"Parent wants the child's height before deciding the width it will
offer"** — the genuinely circular case named in the brief, e.g. a column
that sizes its own width to its widest child, where that child is wrapped
text whose height (which the column's *own* height depends on) depends on
the width the column has not yet decided. This is not solvable in one pass
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
(`span.rs:98-136`) already hits exactly this today and already resolves it
by an explicit second, throwaway pass (its own comment: "this literally
copies draw ... which makes this slow and not cool"). The design keeps that
resolution, made explicit rather than accidental: `Painter` gets
```rust
/// Draw `child` at a provisional region to learn its size under one
/// axis's worth of assumption, discard everything it wrote, then draw it
/// again at the region that assumption produced. For the rare parent that
/// cannot pick an offered size without already knowing the answer.
/// Twice the cost of one `draw`; every other case in this file avoids it.
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
```
implemented as: draw at `first`, record `Size`, remove the widget and its
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
"if not \[same region\], maintain resize and track old children," `:97-100`,
which already frees the old primitives before redrawing) — reusing that
path rather than adding a second one — draw again at `second(size)`, return
the final `Size`. It is opt-in and named for its cost, so a widget only
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
call site converted to it, replacing the hand-rolled duplicate loop.
### 5. Caching and invalidation
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
answer, independent of drawing) no longer exists as a separate query, so
there is nothing left to cache at that layer. What already provides "an
unchanged subtree costs nothing" is the check `draw_inner` performs before
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
its region is unchanged, and it is not marked dirty, `draw_inner` returns
immediately — no `Painter` constructed, no primitive touched, no shader
work beyond what the GPU already redraws from the unchanged instance
buffer. That check is kept exactly as it is; it is the caching mechanism,
and it already operates at (id, region) granularity, which subsumes "(id,
available size)" once size *is* what a region change means.
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
returned, stored the moment it is (`draw_inner`, alongside building the
`ActiveData` struct at `:134-143`). This is what a parent placing this
widget for a second frame without redrawing it (because nothing changed)
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
size question without a full draw" with "read the size of the last actual
draw," which is always available because `draw_inner`'s skip path is only
reachable once the widget has been drawn at least once. `Cache::remove`/
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
`render_state.rs:171-198`, freed with the widget).
### 6. Before / after
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
```rust
// before
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) {
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
thickness: self.thickness, inner_radius: self.inner_radius });
}
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
}
```
```rust
// after
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) -> Size {
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
thickness: self.thickness, inner_radius: self.inner_radius });
Size::REST // fills whatever it was given -- used == available
}
fn is_size_independent(&self) -> bool { true } // content never depends on region size
}
```
**A container that needs the child's size before placing it,
`iris/src/widget/position/align.rs`**:
```rust
// before
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) {
let region = match self.align.tuple() {
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
UiRegion::new(x, UiSpan::FULL) }
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
UiRegion::new(UiSpan::FULL, y) }
(None, None) => UiRegion::FULL,
};
painter.widget_within(&self.inner, region);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
}
```
```rust
// after
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) -> Size {
let full = painter.region();
// Draw once at the full region to learn the child's real size --
// this placement is provisional and corrected below without a
// second draw.
let used = painter.widget_within(&self.inner, full);
let region = match self.align.tuple() {
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
(None, None) => full,
};
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
used
}
}
```
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
return type from `()` to `Size`, carrying the child's `draw` result back —
the only signature change needed to let a parent see what its child used.
`Painter::reposition` is new, computing the delta between where a child
was actually drawn and where it belongs and calling the O(1) `mov` from
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
180-182`) are deleted — nothing calls `desired_len` any more, so there is
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
deletes the `SizeCtx` copies, keeping the `Painter` ones.
### 7. Migration — every file and widget that changes
One change, in dependency order (rename-and-move-together, per the code
rules — no intermediate state with both trait shapes):
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
`WidgetAxisFns`, update `impl Widget for ()`.
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
- `core/src/ui/cache.rs` — delete `Cache` (§5).
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
`len_axis`; `primitive_at` writes `move_idx`.
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
instead of `Cache.size`.
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
`move_slot: MoveIdx`.
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
new `MoveOffset` struct.
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
and `Primitives::write`, matching `mask_idx`.
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
(group 2, beside `masks`) and its update path.
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
function (§2b) called from both `vs_main` (a primitive's own corners)
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
and `window_region` (`:264-267`) reimplemented on top of it.
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
(§2b) — the pointer-routing fix this design requires, not an optional
follow-up.
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
`move_idx: u32` (§2b).
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
never depends on the region it is offered, same as `Rect`),
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
`iris/src/widget/text/edit.rs` (`TextEdit`),
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
`iris/src/widget/position/layer.rs` (`LayerOffset`),
`iris/src/widget/position/pad.rs` (`Pad`),
`iris/src/widget/position/stack.rs` (`Stack`),
`iris/src/widget/position/offset.rs` (`Offset`),
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
cross-axis case, deleting `desired_ortho`'s duplicate loop),
`iris/src/widget/position/sized.rs` (`Sized`).
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
across `core/` and `src/`; re-run it before starting, since it is the
authoritative check that nothing was missed, not this paragraph.
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
`impl Widget` found in any example (verified by the same grep); they use
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
source change, which is itself part of the pass condition below.
### 8. Pass conditions
1. **Every example under `iris/examples` renders identically.** Run
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
"looks right," since a subtle wrap or alignment regression is exactly
what a diff catches and a glance does not.
**Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG
library is installed in this VM (no PIL, no ImageMagick, no pip), so the
diff is a from-scratch PNG decoder (`zlib` + the five filter types) at
`/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than
file bytes (`cmp` alone is not conclusive across two separately-encoded
PNGs, though it happened to agree here for `minimal`). Before-shots were
taken with `git stash` at the pre-change commit; `tabs` needed two real
fixes (deviations 1 and 2 below) before it stopped differing — the other
three matched on the first try.
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
the existing `debug_layers`/`active_widgets` instrumentation
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
`Primitives::write`/`region_mut` calls, both per `update()` call. Drive
one example (`tabs`, since it already has multiple widgets and an
interactive element) through one frame with nothing changed and report
both counts — the pass condition is **0 draws and 0 primitive rewrites**
for a frame in which nothing was marked dirty, resized, or moved.
**Result (2026-09-04): pass, 0 and 0.** Implemented as
`UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut`
rewrites, `move_offsets` writes — a third counter, for condition 3
below), reset on read. Measured in
`iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing`
against a `Scroll` over 500 fixed-height rects (not the `tabs` example —
see the note on condition 3 for why this runs as a plain unit test
instead).
3. **Single-moved-child cost, measured.** Same counters, one frame in
which exactly one widget is moved (not resized) with N primitives in its
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
`Widget::draw`, 0 calls to `region_mut`**, independent of N. Construct
the case with a `tabs`-style example holding a deliberately large text
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
an O(N) regression would show up as a non-trivial write count rather
than being lost in noise.
**Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets
write, N = 500.** Built with rects rather than glyphs
(`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`):
`iris-core`/`iris` touch no GPU or window to lay out and move a tree, so
this runs as a plain `cargo test`, not through `run-headless.sh` — a
`Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is
faster and more precise than reading counters out of a real example's
stderr. Getting a clean single move took two follow-up fixes beyond the
design as written (deviation 3, the `parent_move_slot` threading; and
the `Scroll` design decision below about offering last frame's content
length) — without either, the count was in the thousands (every rect in
the subtree redrawing) rather than 1.
4. **Hit-testing follows the move, not just the render.** In the same
scrolled-`tabs` construction as condition 3, scroll the content, then
send a synthetic cursor position over a widget that moved and assert
`run_sensors` (`src/default/sense.rs:154-200`) routes to that widget's
id, not to whatever is now at its pre-scroll coordinates or to nothing.
This is a correctness check, not a timing one — §2b's fix is required
before §2 can ship at all, and this is what would fail silently
(nothing on screen indicates a missed or misrouted hit) if it were
skipped.
**Result (2026-09-04): pass**, but checked one level below
`run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget`
scrolls a widget and asserts `UiRenderState::resolved_region` (the
query `run_sensors`'s hit-test and `window_region` both now go through,
per §2b) reports the moved, not the pre-scroll, position — within
0.01px of the exact expected delta. `run_sensors` itself needs a
`HasEvents`/window/cursor-state harness this pass did not build; the
coverage that matters (does the position query the router uses reflect
the move) is exercised directly instead.
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
both before and after scrolling it (`iris/run-headless.sh` against a
small purpose-built example, or an addition to `tabs`), and diff the
two frames: the clipped edge of the content must have moved with the
scroll while the viewport's own border (drawn by `Masked`, not moved)
stays put — the specific case worked through in §2b. A mask rectangle
that stayed at its pre-scroll position while its content slid past it
is the regression this checks for, and it is visible in a single
screenshot, not just in a counter.
**Result (2026-09-04): pass, checked numerically rather than by
screenshot.** No example in this repository builds a `Masked`-wrapped
`Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal
scrolling, not this widget), so there was nothing to screenshot without
first authoring a new example. Checked instead in
`iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`,
on the exact data the fragment shader's `resolve_move` reads: the
masked widget's own `move_offsets` slot delta is `[0, 0]` both before
and after scrolling its content, because `Masked` is never itself the
target of a move — only its child is, on a separate, deeper slot in the
chain (§2b's "scroll-container case, checked rather than assumed"). A
pixel-level screenshot check of this remains open; see RUST.md's next
step.
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
stay clean at the defaults (iris has no tests today per I0b, so this is
presently only clippy/fmt; add the first real widget-layer tests here if
the move-offset chain or `draw_twice` are non-trivial enough to want
one, per "match the codebase's testing posture" — judge that once the
code exists rather than pre-committing to a number of tests here).
**Result (2026-09-04): pass.** `cargo fmt --all -- --check`,
`cargo build --workspace --all-targets`, and `cargo clippy --all-targets`
are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/
`winit` future-incompatibility, from dependencies, not this change).
`cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new
ones in `iris/src/layout_tests.rs` (conditions 25 above), 18 passed, 0
failed — the move-offset chain turned out non-trivial enough (three real
bugs found only by writing it) to clearly clear the "match the testing
posture" bar this section left open.
### 9. Rejected, and why
- **A flat (non-chained) per-subtree offset table**, Iris's literal
phrasing — rejected in §2 for breaking under nested independent moves
(a swiped row inside a scrolling list). Costs nothing extra to avoid: the
chain is the same mechanism with one more field.
- **Keeping `region_mut` recursion as the only move mechanism** — rejected
as the steady-state path (O(primitives in subtree), exactly what a
transcript scroll must not pay every frame) but kept for resize-shaped
changes (§3) where the content's own region field, not an ancestor
chain, is what has to change.
- **A second, size-only trait method kept alongside `draw`** (e.g.
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
implement to skip a draw when a cheap answer exists) — considered and
rejected: it reintroduces exactly the "two names for one concept" split
this change removes, for a saving `is_size_independent` (§1, §3b)
already covers for the cases where it would actually help (fixed-size
leaves). A widget whose size is cheap to compute but whose *drawing* is
not (unlikely in this codebase's widget set, but conceivable) is better
served by that widget caching its own draw output internally — exactly
the pattern `TextView::render` already uses (§4) — than by a second
trait method every implementor has to reason about.
- **Passing `available` as an explicit parameter to `draw`** (mirroring
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
which already carries the same information into every widget that needs
it; adding a parameter would just be a second route to a value already
reachable, and would invite the two drifting apart.
- **Eagerly propagating a moved widget's delta into every descendant's own
offset value** (rather than chaining and resolving in the shader) —
rejected as O(descendant widgets), which is smaller than O(primitives)
but still not O(1), and the shader-side chain costs nothing extra to get
the better bound.
## Deviations found during implementation (2026-09-04)
Five corrections this file's first draft did not anticipate, each found by
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
`draw_inner`/`reposition` — not by reasoning about the design in the
abstract. Recorded here rather than silently fixed in place, per the code
rules' escape-hatch requirement.
1. **`Aligned`'s provisional draw must call `painter.widget`, not
`widget_within(&self.inner, painter.region())`.** §6's original text drew
the sample as the latter. `widget_within` composes its `region` argument
as *local*, `UiRegion::FULL`-relative coordinates against
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
self.region` relies on); handing it `painter.region()` itself —
already-resolved, window-relative coordinates — composes that frame a
second time. For the root widget this is silently the identity (its
region already is `[0,1]`), which is why it can look correct in a
trivial case and only breaks once something is nested — i.e. always, in
practice. Symptom: a centered child rendered at a wildly wrong offset
nested more than one level deep. Fixed by using `painter.widget`, which
hands the child `self.region` unmodified, with no second composition.
2. **A widget that reports a size smaller than its offered region must
actually paint at that size, anchored top-left of what it was given —
not fill the full offered region while merely *reporting* a smaller
number.** `Sized` and `MaxSize` both had exactly this bug: their
`desired_width`/`desired_height` predecessors capped the *reported*
value but their `draw` bodies called `painter.widget(&self.inner)`
unconstrained, which was harmless under the old two-pass model (a parent
always queried the size *before* drawing, so by the time `draw` ran the
offered region already matched) but wrong under `Aligned`'s new
provisional-draw-then-reposition pattern, which offers the *whole*
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
rendered stretched to fill its whole row instead of a 100×100 square.
Fixed by having both widgets carve the declared sub-region (`UiSpan`
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
they were offered before drawing the child in it. `Image` needed the
same treatment from the start (`texture_within` at its own natural size,
not `texture()` at the full offered region) and was written that way in
the first pass, once this was understood; `Rect`'s "fill whatever I'm
given" is the one case where painting the *whole* offered region really
is the declared behavior, so it needed no change.
3. **The move-offset chain's `parent` link cannot be found by looking up
the parent's `ActiveData` in `draw_inner`, because the parent's
`ActiveData` does not exist yet while its own `Widget::draw` is still
running.** `ActiveData` is inserted only after `draw` returns
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
through its parent's `draw` body — the ordinary case, since every
composite widget draws its children from inside its own `draw` — would
always read "no parent" from `self.active`, silently orphaning it at the
root of the chain. Fixed by threading the parent's `move_slot` down
through `Painter` (it already carries `mask`/`layer` the same way) and
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
target's parent genuinely is already active at that call site — the
doc comment on it says which is which. Symptom: `reposition` computed
the right delta and wrote it to the right slot, but the shader never
saw it, because the primitive doing the actual painting chained to
`u32::MAX` one level too early.
4. **`Painter::reposition` cannot reuse `active.region` as "where the
widget currently is," because for a widget offered more room than it
used, `active.region` is the *offered* box, not the *painted* one.**
This only matters for `reposition` (used by `Aligned`); `mov` (used by
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
and `Offset`) has no such gap, because there the offered region *is*
the visual footprint — content is sized to fill exactly what it is
given. `reposition` instead reconstructs "from" as `active.size`
(already tracked, per §5) anchored at `AxisAlign::Neg` within
`active.region` — i.e. it assumes the child painted itself top-left of
whatever it was offered, per point 2's convention — and **overwrites**
the slot's delta rather than accumulating it the way `mov` does, since
"from" is recomputed fresh from stable inputs every call and repeating
the same `reposition` (an unrelated redraw elsewhere re-running this
widget's parent) must not drift further each time. The one shape this
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
`reposition` may have moved its content away from top-left already. No
widget or example in this codebase builds that today; if one needs to,
`reposition` would need the child to report *where* it painted, not
just how big, which is a larger change than this pass's scope.
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
draw, and reused in place — never reallocated — for every later redraw
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
spelled out in §2's original text, which only said slots are assigned
"when the widget is first drawn." Reallocating a fresh slot on every
redraw would leave any *retained* (not-redrawn) descendant's `parent`
link pointing at a now-orphaned old slot — a permanent leak, and worse,
a descendant that silently stops tracking its ancestor's future moves.
Resetting the delta on reuse (rather than carrying it forward) is
required because a full redraw bakes the widget's correct absolute
position into the fresh `region` argument directly; a stale delta left
over from before the redraw would double-offset it.
Two further points worth recording because they were *design decisions*
made while implementing, not bugs — `LAYOUT.md`'s own text left them
unspecified rather than getting them wrong:
- **`Scroll` offers its content a region sized by the *previous* frame's
measured content length, not a fresh one.** A fresh measurement would
require drawing the content once to learn its size and — since that
provisional size essentially never matches the previously active one —
redrawing it a second time at the real size, on every single scroll
tick, which is exactly the cost §2 exists to remove. Using the stale
length means an ordinary scroll (position changes, content does not)
offers the same *size* as last frame, only shifted, which is what makes
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
change lags one frame before the container's scroll range reflects it,
self-correcting the frame after (the content length itself, read from
what was actually drawn, is never stale — only the offered *region* used
for placement is). No example in this repository builds a `Scroll` yet,
so this could not be checked against a pixel diff; it is covered instead
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
build a tree and drive `UiRenderState` directly with no GPU or window
needed.
- **`redraw()`'s parent-relayout check draws the widget first, then
compares the fresh `ActiveData.size` the draw produced against the size
from before removal** — the mirror image of the old code's "query size,
compare, decide whether to draw," which no longer has a size query to
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
along with `desired_width`/`desired_height`). This can occasionally draw
a widget once more than the old code would have (if the parent it
bubbles up to ends up redrawing the same widget again as part of its own
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
redundancy for free, and this path is not one of §8's measured
conditions, so the remaining slack was accepted rather than chased
further.
## For IRIS.md
When this lands, copy this entry into `IRIS.md` (newest first):
> **2026-09-04 — `Widget::draw` reports the size it used; `desired_width`/
> `desired_height` are gone.** A widget used to implement three methods
> (`draw`, `desired_width`, `desired_height`); it now implements one,
> `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into
> `painter.region()` and returns how much of it was used. Why: the two
> extra methods routinely re-simulated what `draw` was about to do anyway
> (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing
> right) — one visit per widget per frame instead of up to three. A
> container that needs a child's size before placing it (alignment,
> centering) draws the child once at a provisional region, reads the
> returned `Size`, and calls the new `Painter::reposition` to move it into
> its final spot — an O(1) offset write, not a second draw. A widget whose
> drawn output never depends on the size it's given (a fixed-size `Rect`,
> a decoded `Image`) overrides the new `fn is_size_independent(&self) ->
> bool { false }` to `true`, which skips redrawing it when only its
> offered region changes shape.
>
> ```rust
> // before
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
>
> // after
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
> ```
>
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
> design, the move-offset mechanism this shipped alongside, and the file
> list.
+85 -1180
View File
File diff suppressed because it is too large. Load diff
+2632
View File
File diff suppressed because it is too large. Load diff
-289
View File
@@ -1,289 +0,0 @@
# Subagents
A session's subagents -- helpers started by Claude Code's Task tool or Codex's
collaboration tools -- each get a transcript of their own, listed in a panel
over the open session and readable in the same transcript view the session has.
Designed 2026-09-05; extended to Codex's multiplexed app-server threads on
2026-09-13. 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 machine, 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.
Claude 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.
Codex app-server multiplexes every thread in the session tree onto the root
process's stdout. Its notifications carry `threadId`; `subAgentActivity`
items name the child thread and its lifecycle, and `collabAgentToolCall`
items carry the spawn prompt. The Codex translator routes a non-root
`threadId` exactly as Claude routes a `parent_tool_use_id`. The child thread
id is the subagent id on disk. An asynchronously delivered `agentMessage` is
a `PeerMessage`, not assistant text from the recipient. Its delta notification
does not repeat the completed item's `delivery` field, so the translator
remembers that field from `item/started` and suppresses those deltas. Letting
one into the recipient's provisional assistant row makes its next completed
message replace the combined row, visibly erasing text that Codex still has.
The parent draws the initial `spawnAgent` as its ordinary `Task` card and
closes it when the matching `subAgentActivity.started` arrives. The remaining
collaboration calls remain visible as coordination -- waiting, messaging,
listing and lifecycle controls -- rather than being mistaken for generic task
output. Null optional fields and a bare `completed` status carry no information
and are omitted; their useful result is the child transcript, status or peer
message beside them.
## Storage
Under the session directory:
```
<session>/subagents/<subagent_id>/meta.json {title, created}
<session>/subagents/<subagent_id>/transcript.jsonl same SeqEvent lines as the session's
```
The id is Claude's Task tool_use id (`toolu_…`) or Codex's child thread id.
Both are unique, stable across a backend restart, and already the key their
parent-side lifecycle 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,
and `POST /sessions/{id}/subagents/delete` removes finished ones on their own
-- all or nothing, and refused while any named one is still running, since its
transcript is still being written to and its process is the session's to stop.
## Lifecycle, as events in the subagent's transcript
1. Created when the parent Task/Agent call is seen. A current Claude CLI's
`task_started` with `task_type: local_agent` is a recovery source when an
adopted stream begins after that call. A bare `parent_tool_use_id` is not
enough: other operations can also parent nested lines, and treating one as
proof created false subagents named after their first subcommand.
First lines written:
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
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. **What ends a subagent is the CLI's own task lifecycle**, on top-level
`system` lines that carry no `parent_tool_use_id`: `task_started`
(`task_id`, `tool_use_id`, `task_type`, `is_backgrounded`, the prompt),
`task_progress` repeatedly, then `task_updated` (`patch.status`, naming the
*task* only) and `task_notification` (`tool_use_id`, `status`, and `summary`
-- the agent's own report). `translate_task` keeps the
`task_id -> tool_use_id` mapping from the first so the update can be
attributed, records the summary as the subagent's closing text, and writes
`Status Exited`. A
`completed` update is deliberately not the end: its notification carries
the summary and would otherwise land after the ending. Any other terminal
status ends it from the update, since the failure to avoid is a subagent
nothing ever finishes.
Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is
the authoritative level beside those edges: its set replaces the previous
set, so a missed terminal edge cannot leave a subagent running forever. Its
ids are deliberately not correlated with the edge stream; what is read off
each entry is its own description and kind, and what is read off the set is
whether it is empty and how large. The session API and stream expose that
size as `backgroundTasks`, which the phone draws beside the status, and
`GET /sessions/{id}/background` serves the entries themselves -- listed in
the session's panel *above* the subagents and never as subagent cards. An
`ambient` entry is excluded from both, on the CLI's own instruction: a
live-update watcher is not activity. A backgrounded subagent is legitimately
in both lists, since it is both running and a transcript. The edges still
carry mapping, outcome and closing summary. On adoption the driver sends a repeated `initialize`,
which makes a current CLI send the full set; an older CLI accepts it and sends no level,
leaving the edge-based path unchanged. A snapshot is reconciled immediately
when the persisted parent status proves it is between turns, and otherwise
at the next `result` boundary -- while a turn is open, a foreground agent is
legitimately absent from the background set. Reconciliation writes
`Status Exited`, which is also what makes a formerly stale row deletable;
a task notification ordered after the level can still add its summary.
The two rules this replaces were both wrong, in opposite directions. The
parent's `tool_result` is not it: a backgrounded Task's arrives at launch
("Async agent launched..."), so ending there truncated a running agent's
transcript at the moment it started. Nor is the subagent's own
`end_turn`: measured against 2.1.237 on 2026-09-06, **a subagent's lines
carry no `stream_event` at all** -- they are whole `user`/`assistant`
lines with a null `stop_reason`, no `result` line is sent for one, and the
sub's final report never appears as a child line -- so that rule could
never fire and every subagent stayed `running` for ever. `ends_a_turn` is
kept as a second detector for a dialect that does say either, and must
never be the only one again.
`Status Exited` either way; the subagent's vocabulary has no `Idle` or
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
session is dropped rather than written.
**The ending reaches the parent's transcript as nothing at all**
(2026-09-06). It was tried, and a row per finished subagent is a screenful
of dividers about work the reader was not asking after; the closing report
is *this* transcript's last line and here is where somebody reads it. What
the parent gets a row for is a message a subagent genuinely sends it, which
arrives by the peer path. A backgrounded *command* is the other half of
this and goes the other way: it has no transcript of its own, so its report
updates the tool card that launched it, which was still saying the command
was running. The two lifecycle shapes are still handled once:
whichever gets there first is the one that finds the task still open, and
`finish` below closes it. See PLAN.md's "Two turns must never be drawn as
one".
**While any task is outstanding the session's turn ends in
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
and a session with a backgrounded subagent is not doing that. The edge
fallback has two sources: the translator's `open_tasks`, and
`Subagents::any_open` -- which covers a subagent launched before a backend
restart adopted the session, whose `task_started` is behind the durable
stdout offset. On current Claude versions the replace-semantics level above
reconciles both at a safe turn boundary.
**A limit the account hits inside a subagent is hoisted to the session**
as well as recorded here, because `resume.rs` can only schedule against a
session, and a background subagent outliving its parent's turn is the
ordinary case -- see PLAN.md's "A limit a subagent hits is the session's".
4. **A child line for a subagent that already finished reopens it**
(`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. Read from the directory rather than from the
live map, because one left `Running` by a previous run of the server is
precisely the one nothing in this process has touched -- and it would
otherwise read `running` again every time its session was started.
For Codex the same lifecycle is expressed by app-server rather than Claude's
task notices: `subAgentActivity.started` creates the child,
`subAgentActivity.interacted` reopens it, and `completed` or `interrupted`
finishes it. A child's own `turn/completed` is not its end; it remains running
until that activity edge. The root's `turn/completed` reports `waiting` while
the registry contains an open child, and the last activity completion reports
`idle` if the root is between turns. Because the child thread id is also the
on-disk id, an adopted driver can route and finish a child whose spawn record
is already behind the durable stdout offset. The registry's open count is also
Codex's `backgroundTasks` measurement: lifecycle changes send it through the
same event and session-summary fields as Claude's provider snapshot. The other
part of that measurement is app-server's runtime
`thread/backgroundTerminals/list` set. Its process ids are held only in memory
and added to the open-child count; the driver refreshes the set at terminal
boundaries and while it remains nonempty, rather than decrementing for an
unmatched ending edge.
A subagent that was mid-flight when the backend restarted keeps working:
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: for Claude, the Task call's `description` input, then
` (<subagent_type>)` when one is given; falling back to `Task` when the
description is absent. An adopted current CLI can recover the same fields from
its `local_agent` lifecycle record. For Codex, the first lifecycle record uses
the spawned thread's name or the last segment of `agentPath`, with underscores
shown as spaces, then falls back to `subagent`.
## Server layout
- `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, and
`delete(ids)` -- its path out. 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/codex/translate.rs` -- routes multiplexed app-server notifications
by thread id, remembers collaboration prompts, and translates activity
edges into the same registry lifecycle.
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
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` -- four 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
POST /sessions/{id}/subagents/delete {subagents} -> 204; refused whole if one is running
```
The delete is a batch rather than a `DELETE` per id for the reason the import
list's is: the phone deletes what a reader selected, and one request per row
means a batch can half-arrive, leaving the rows that were missed looking
exactly like rows nobody picked. Unlike an import delete it is local file
removal, so it is done by the time the reply is sent and there is no per-row
state to follow afterwards. What decides "running" is
`Subagents::list`'s own rule, shared through `routes::has_a_process` so the
list and the delete cannot disagree about it.
`status` is the transcript's last `Status` event, serialised like a session's
(`running`, `exited`), except that a subagent whose session is not itself
running cannot be running: the list answers `unknown` for that one. A
subagent never reports `waiting`: that is a session's word for having
outstanding work of its own, and a subagent has none. The
phone words these as *running*, *finished* and *unknown* on the subcard.
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
- The subcards are ordered **still running first, then most recently
active** -- a display decision made on the phone (`subagentOrder`), over the
server's stable oldest-first answer. Two keys rather than activity alone
because a subagent that is thinking reports nothing meanwhile and would sink
below one that just finished.
- **Holding a subcard selects it, and several at a time**, exactly as the
import list works, with the selection bar drawn inside the panel rather than
at the bottom of the session: this selection belongs to the subagent list,
and a bar under the composer would read as acting on the conversation.
Delete is
*disabled*, with the reason in words, while anything selected is still
running. Deleting confirms first, dims the rows it is acting on
(`BusyItem`), and on success takes them out of the panel without refetching
anything else. The phone's cached copy of
a deleted subagent's transcript is purged with it.
- The main session list does not expand or count subagents. Swiping left over
an open session pulls an 88%-wide panel in from the right and fetches
`/sessions/{id}/subagents`; it draws one `OutlinedCard` per subagent: title,
then the status word and a relative time. The transcript remains composed
under the panel, so its event stream, draft and scroll position stay live.
Horizontal scrollers inside the transcript win the gesture. Collapsing one,
or starting over any ordinary part of the session, gives the gesture back to
the panel; Android keeps its own edge Back gesture. Swiping right on the
panel, tapping outside it, or Back closes it.
- Tapping a subcard opens a `SessionScreen` layer in **read-only** form: the
same transcript, paging, cache, selection,
images and status row, with the composer, the process button, the model
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.
It is another layer over the still-composed session and its panel; Back
returns to the panel.
- 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.
+496
View File
@@ -0,0 +1,496 @@
# How iris should render an unbounded number of images
## Status (2026-09-04)
**Implemented**, on the `rustify` branch of `ai-app-2`, in `iris/core` and
`iris/src/default/render.rs`. See "Implemented, 2026-09-04" at the bottom for
what landed, what differs from the proposal below and why, and what was
verified versus merely reasoned about. The short version: the binding array
is gone, `request_device` asks for no features and no binding-array limits,
and that is now proven on the emulator's software Vulkan
(`rigs/gpu-probe`), not just read from the code. `RUST.md`'s blocking item
is resolved.
Iris (the person) asked whether iris's (the library's) approach to
"draw however many images happen to be on screen" — relevant here because a
transcript can hold an unbounded number of attached screenshots — actually
works on mobile, her recollection being that it does not. Checked rather
than assumed, on 2026-09-04, on the `rustify` branch of `ai-app-2`. This
file is that investigation and the resulting recommendation, written for a
second agent to review before anything in iris's render core changes — no
code has been written against this yet.
## The problem
Every texture iris ever creates — every `Image` widget
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
array by index: `core/src/render/shader.wgsl:56` declares
`var views: binding_array<texture_2d<f32>>`, sized by
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
1,000 samplers**. Getting a device to accept that layout needs three wgpu
features — `TEXTURE_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
A transcript with an unbounded number of image attachments is exactly the
case that grows this array without bound: each attachment becomes its own
`Image` widget, which takes its own permanent array slot until dropped.
## What was measured
**A new rig, `rigs/gpu-probe`**, asks a device for exactly iris's features
and limits with no window and no APK — a plain executable pushed with
`adb push` and run from `/data/local/tmp`. It has two parts:
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
detect it."
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
discrete desktop GPU — but it is why the design was never checked before
now: it always worked in the one place it was tried.
- **On the Android emulator's guest Vulkan**, both ICDs it ships
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
`request_device` **fails**
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
driver itself reporting all seven descriptor-indexing sub-features as
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
being more conservative than the driver here, for a reason not chased
further (a likely instance-version negotiation gap, since the extension
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
not the finding that matters, and is **not** why this design is rejected.
**The finding that matters is about real phones, sourced rather than
recalled:**
- The **Android Vulkan Profile 2025** — Google and Khronos's current
baseline, covering **80.1% of active Vulkan-capable Android devices** as
of October 2025
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)) —
does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
(indexing by a value uniform across the invocation — Vulkan 1.0 baseline,
unrelated to bindless) and stops there; true of the 2021 and 2022
profiles as well.
- Arm's own developer documentation states **"`VK_EXT_descriptor_indexing`
is supported on all Valhall and 5th Gen GPUs"**
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
Mali generations from roughly 2019 (Mali-G77) onward, with no claim made
for Bifrost, Midgard or Utgard, which are still common in budget and
older Android phones that are still in daily use.
- A search engine's summarized claim of "1% support on Android" for this
extension was checked against its cited source (an Arm blog post from
2021) and **was not actually there** — that number does not appear in
any primary source found and should not be repeated. The baseline-
profile finding above is the one with an attributable source; use it
instead.
So this is not a software-renderer artifact. A real, currently-shipping
share of the Android fleet lacks the feature iris's texture pipeline asks
for unconditionally, and neither the emulator's failure nor the current
official hardware baseline gives any reason to expect that to change soon.
## What growth already costs today, before any redesign
Checked directly in `core/src/render/mod.rs` and `core/src/render/texture.rs`,
because "does this redesign make things worse" needs the current baseline
first:
- The `RenderPipeline` (`UiRenderNode::new`) is created **once** and never
rebuilt for any reason related to texture count — its bind group
*layouts* declare fixed slot counts (`limits.max_textures`,
`limits.max_samplers`) up front and that never changes at runtime. Growth
was never at risk of recreating the pipeline, in the current design or
any redesign discussed below.
- What **does** get rebuilt: `UiRenderNode::update` calls
`self.textures.update(&mut ui.textures)`, and if that reports any change,
rebuilds `self.rsc_group` — one `BindGroup` whose entries are
`BindingResource::TextureViewArray(&tex_manager.views())`, collected
fresh over **every currently-live texture**, plus the sampler array and
the mask buffer. This happens on every texture `Push`, `Set`, or `Free`
— an image added anywhere in the whole app rebuilds one shared structure
referencing every other image too.
- The one path already excluded from this, on purpose, is a `Patch`
writing into an existing texture's pixels without changing which
textures exist. The code says why directly
(`core/src/render/texture.rs`, in `GpuTextures::update`): *"A patch
changes texture contents, not the binding array, so it must not report
`changed` — rebuilding the bind group per glyph is the cost this exists
to avoid."* This is exactly the mechanism I1 built for the glyph atlas:
growing an existing atlas page costs a `write_texture` into a sub-rect,
nothing else.
So today, growth that stays inside an existing texture (glyphs added to an
atlas page) is already free. Growth that adds a *new* texture — a new atlas
page, or any standalone image — already rebuilds the one shared array
regardless of how the array is populated, before any change discussed
below. That existing cost is O(live texture count) in CPU work to collect
the view list and in however expensive the driver finds a
descriptor-set-sized-for-N-descriptors to be.
## Prior art, checked rather than assumed
Two independent projects were checked to see whether "atlas for images"
is actually how this is normally done, rather than a guess:
- **egui_wgpu** (`crates/egui-wgpu/src/renderer.rs` in emilk/egui), the
closest prior art to iris — an immediate-mode wgpu-backed UI library that
ships on Android. It keeps a `HashMap<TextureId, Texture>` and gives
**each texture its own ordinary `BindGroup`** — one texture, one sampler,
no array, no descriptor indexing of any kind. Draw calls are batched by
texture id and the bind group is switched between batches within the
render pass.
- **Vello** — the renderer Masonry (E1/E2's Linebender stack) draws
through — hit the identical problem and wrote down why in their own
roadmap document
([github.com/linebender/vello/blob/main/doc/roadmap_2023.md](https://github.com/linebender/vello/blob/main/doc/roadmap_2023.md)):
*"The number of images that may appear in a scene is not bounded, which
is not a good fit for the basic descriptor binding model... Until then,
we'll do a workaround of having a single atlas image containing all the
images in the scene."* Their reason is broader than Android — WebGPU 1.0
has no descriptor indexing at all — but it reaches the same conclusion
for the same shape of problem: atlas, not a bigger bindless array.
**This is also a live hazard, not a solved one.** Vello's own changelog
(Sparse Strips v0.2.0) lists a fix titled *"WebGL image-atlas allocation
and growth on Mali-G52 GPUs, avoiding application-not-responding errors"*
— an actual ANR, from atlas growth, on an actual mid-range Android GPU,
in the renderer Masonry is built on. The same release added
`AtlasSpaceDiagnostics`/`AtlasLayerDiagnostics` (per-layer free-space,
utilization, fragmentation) because growth needed instrumenting in
production, not because it turned out to be free.
## Recommendation (not yet implemented)
1. **Small, plentiful textures** — glyphs (already done, I1), thumbnails,
downscaled attachment previews, icons — go through a shared atlas, the
same technique as `core/src/render/atlas.rs` generalized beyond glyphs.
Adding one to an existing page is a `Patch`, already free per the
section above.
2. **Large or one-off images** — a photo attachment opened at full
resolution, anything that would fragment a shared page — get their
**own ordinary, non-array bind group**, the egui_wgpu way. Creating one
is O(1): it references only itself, and does not touch any other
texture's binding, unlike today's shared array where every push
rebuilds a structure listing everything.
3. **Opening a new atlas page** is the one case that still resembles
today's rebuild — infrequent (bounded by how many *pages* are needed,
not by how many images have ever been attached) but not free, and
Vello's Mali-G52 fix says this specifically deserves care: it should
never be allowed to block a frame, and it is worth having the
equivalent of Vello's atlas diagnostics before trusting it under load.
4. **Net effect**: dropping `TEXTURE_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
`PARTIALLY_BOUND_BINDING_ARRAY` from iris's device request entirely.
Every path above is plain Vulkan 1.0 / GLES-level texture sampling.
This is also what fixes the emulator failure measured above, regardless
of the unresolved wgpu-hal question: a device that never asks for the
feature cannot be refused for lacking it.
## What this touches, and what is still open
Implementing this reworks iris's rendering core: the shader's binding
group layout (`shader.wgsl`), `Textures` and `GpuTextures`
(`core/src/primitive/texture.rs`, `core/src/render/texture.rs`), both
texture-sampling primitives, and `core/src/ui/painter.rs`'s draw-call
batching (today one draw call can reference any texture by index; the
per-texture-bind-group path needs draws grouped by which bind group they
use). Nothing has been started.
Open questions a reviewer should weigh in on:
- **The size threshold** between "goes in an atlas page" and "gets its own
bind group." Too low and ordinary attachment thumbnails end up as
one-off bind groups, losing the batching benefit the atlas exists for;
too high and a page fragments on a handful of medium images.
- **Eviction policy** for atlas pages once the working set does not fit —
today's `GlyphAtlas` never evicts, because a font's glyph set is small
and bounded; images are not. An LRU at the page level, or at the
individual-image level within a page, has not been designed.
- **Whether iris should keep any binding array at all**, even a small
fixed one (say, capped at a few dozen slots) for atlas pages themselves,
or whether every atlas page should also be its own ordinary bind group
like standalone images — the array's only remaining justification would
be avoiding a bind-group-per-draw-call switch cost that has not been
measured on this project's actual target hardware.
- **How this interacts with I2/E2's virtualised list** (I3): a
bottom-anchored transcript composes only visible rows, so the live
texture set should already be bounded by what is on screen rather than
by the whole conversation — worth confirming that invariant holds before
relying on it to keep atlas/bind-group churn small.
## Review, 2026-09-04
A second pass over the file above against the code, done before anything
is implemented. Iris's worry going in: a bind group per texture means a
draw call per image, and she wants this as efficient as it can be.
### What checked out
Every code reference above is accurate as of this commit: the 100,000 /
1,000 limits, the one-time pipeline, the `rsc_group` rebuild on every
`Push`/`Set`/`Free`, and the `Patch` exclusion. The device request that
asks for the three features is `iris/src/default/render.rs:96`, which the
text above does not name. egui-wgpu and Vello are described correctly.
### The emulator refusal is a wgpu-hal gap, now located
The file guessed "a likely instance-version negotiation gap." It is
narrower than that and it is in wgpu-hal, not the emulator. wgpu-hal
28.0.0 (`src/vulkan/adapter.rs:1618`) only queries
`PhysicalDeviceDescriptorIndexingFeaturesEXT` **when the device advertises
the `VK_EXT_descriptor_indexing` extension string**. A Vulkan 1.2+ driver
that has descriptor indexing as core need not list the extension, and
lavapipe at 1.3 evidently does not, so wgpu never asks and reports the
features absent, which is why `ash` sees seven `true`s and wgpu sees none.
The properties query beside it (line 1486) correctly accepts
`device_api_version >= 1.2 || extension`; the features query does not.
wgpu-hal 30.0.1 in the local registry has the same asymmetry (lines
1872 and 2036). Worth an upstream issue, but not a reason to keep the
design: on real phones the gate that matters is stricter still.
**wgpu's `TEXTURE_BINDING_ARRAY` needs six sub-features, not one**
(`adapter.rs:160-177`): non-uniform indexing *and* update-after-bind for
sampled images, storage images and storage buffers, all together, because
wgpu marks every array-bearing descriptor set update-after-bind. So Arm's
"the extension is supported on Valhall" is necessary but not sufficient;
a driver with sampled-image indexing and without storage-buffer
update-after-bind is refused too. That widens the excluded set beyond
what the Arm quote suggests and strengthens the conclusion.
### A live bug in the current code, found on the way
`GpuTextures::update` (`core/src/render/texture.rs:33`) implements
"a patch must not report changed" as `changed = false`, unconditionally,
which also **cancels a `Push` earlier in the same batch**. That ordering is
exactly what opening a new atlas page produces: `GlyphAtlas::allocate`
pushes the page and `insert` patches it in the same frame, so the bind
group is not rebuilt and the new page's view is not bound until some
unrelated texture change happens to rebuild it. It is hidden today only
because the masks path also sets `changed`. The fix is one line
(`changed |= !matches!(update, Patch)` in spirit); it should go in with
the redesign since that code is being replaced, and it is recorded here
so it is not rediscovered.
### In-layer draw order is already undefined
Relevant to any batching redesign: `Primitives::apply_free`
(`core/src/render/primitive.rs:147`) uses `swap_remove`, so the instance
order within a layer is permuted whenever anything is freed. Overlap order
inside one layer is therefore not something the renderer promises today;
ordering is done with layers. That means grouping a layer's draws by
texture, or drawing a layer's images after its rects and glyphs, loses
nothing that currently exists. It should be written down as an invariant
when the redesign lands, because the new code will depend on it.
### On "a draw call per image"
Two corrections to the worry. First, it is a draw per *distinct texture per
layer*, not per image primitive: every glyph quad in a layer shares the
atlas and stays one instanced draw, and a thumbnail atlas would do the same
for previews. Second, the count is bounded by what is on screen, which I3's
virtualised transcript already bounds, and a mobile GPU is not draw-call
bound at tens of draws per frame; egui ships exactly this on Android. What
does cost is per-frame *bind group creation* and per-frame *sorting*, and
the current code already creates a `primitive_group` bind group every time
a layer updates (`render/mod.rs:103`), so one more per new image is not a
regression in kind.
### Recommended shape (proposal, for Iris to accept or change)
Aimed at the fewest moving parts that need no feature beyond Vulkan 1.0:
1. **Atlas pages become layers of one `texture_2d_array`**, not separate
textures. Every page is already `PAGE`x`PAGE` RGBA8, which is the one
constraint an array texture imposes. A layer index is an ordinary
sampling operand in WGSL and needs no indexing feature, so `GLYPH`
(and any future atlased-image primitive) carries a layer instead of a
`view_idx` and all of a layer's text stays **one draw**. This answers
the open question above about keeping a small binding array: no. Cost
of opening a page: recreate the array with one more layer and
`copy_texture_to_texture` the old ones, GPU-side, no readback; grow
with headroom (double) so it is rare. wgpu's default
`max_texture_array_layers` is 256, at 4 MB each, so the cap is memory
rather than the API.
2. **Every standalone image is its own texture with its own bind group**,
and its instances live in a **separate per-layer instance list**, not
the main one. Then the main instance buffer never contains an image,
there is nothing to sort, no handle remapping beyond what
`apply_free` already does, and each image is `draw(0..4, k..k+1)` with
its bind group set first. Group 2's layout becomes `{atlas array,
one image texture, sampler, masks}`; the main draw binds a 1x1 null
image in the image slot, each image draw binds its own. One pipeline,
one shader, one layout.
3. **No thumbnail atlas in the first version.** With images on their own
textures, the threshold and eviction questions above disappear: an
image is freed when the row that owns its `TextureHandle` scrolls out.
Add an image atlas only if a measured screen shows enough small images
to matter, which a transcript rarely does.
4. **Drop the three features and the two `max_binding_array_*` limits from
`src/default/render.rs`**, and the `UiLimits` counts with them.
5. **Sampling is `NonFiltering` today** (`render/mod.rs:290,299`), so a
downscaled attachment will alias. Either request a filtering sampler
for the image slot or downscale on the CPU before upload; decide when
the image widget is touched, not as part of this.
What this costs against the file's original recommendation: `Textures`
needs to know an image from a page (two kinds of handle, or a kind on
`TextureHandle`), and `Primitives` gets a second instance list per layer.
What it saves: the sort, the size threshold, the eviction policy, and any
per-page bind group switch.
## Implemented, 2026-09-04
The shape above, built as proposed with one structural addition the proposal
didn't need to spell out and one bug it predicted made moot rather than
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
`rigs/gpu-probe/src/main.rs`.
**1. Atlas pages as array layers.** `GpuTextures` owns one
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
(`grow_array`): a new texture is created at twice the layer capacity, the
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
readback), and every bind group that referenced the old view — the main
one and every live standalone image's — is rebuilt, since the view's
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
renderer, because `GlyphAtlas::insert` needs it in the same call, before
any GPU sync happens — the renderer only finds out later, when it
processes the queued `Push`.
**2. Standalone images, one bind group each.** `TextureKind` on
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
`images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
`instances` (rects and glyphs), written by `Painter::write_image` rather
than through the generic `Primitive` trait, since an image has nowhere in
`PrimitiveData` to put a per-instance entry once the bind group already
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
once as before, then walks `image_instance` one entry at a time, binding
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
1x1 null view in the image slot.
**The one addition beyond the proposal**: the masks storage buffer lives
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
recreates its buffer whenever the mask count changes size
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
A resize invalidates every bind group holding the old buffer, not just the
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
array-growth path already needed. This wasn't a design question the
proposal had to answer (it treated bind-group construction as a given),
but it's exactly the shape of trap layer growth already had, so it uses
the same fix.
**3. No thumbnail atlas.** Not built, as proposed.
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
itself, not just its binding-array methods — once its two fields were
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
a limits parameter). `binding_array` no longer appears anywhere in
`shader.wgsl`.
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
note that this is a separate decision for whenever the image widget itself
is touched.
**The `changed = false` bug is structurally gone, not patched.** The old
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
unconditionally, which could erase an earlier `Push` in the same batch (a
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
`Patch`, both queued before the renderer ever runs). The new `update`
computes the rebuild signal by OR-ing each event's own answer
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
contributes to it — there is no shared mutable flag left for a `Patch` to
stomp on. Documented at the call site
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
`Patch` match arm's comment) rather than fixed as a one-line diff, since
the mechanism that could go wrong no longer exists.
**In-layer draw order is an explicit invariant now, not just a fact about
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
rects and glyphs, and `Primitives::apply_free`'s doc comment states
directly that both of a layer's lists (`instances` and `images`) free with
`swap_remove` and that nothing may assume adjacency survives a free —
recorded there because `apply_free` is the one place a change to either
list's ordering would have to be reconciled.
**Verified:**
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
(unchanged from I1; nothing here is pure-logic enough to add a unit
test to — it's all GPU resource wiring).
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
- The standalone-image path specifically: a throwaway example (not
committed) with an `image(...)` widget as part of the root, run the same
way, rendered the image next to glyph-atlas text in one frame —
confirming a live `BindGroup` built by `GpuTextures::create_image` and
bound per-`draw()` call actually samples the right texture. `tabs`'s own
"image span" tab exercises the same widget but needs a click to reach,
which the headless compositor can't deliver (no seat devices, per I1's
own note on this file) — the throwaway example is what stood in for it.
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
Rather than building a purpose-made glyph flood, `PAGE`
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
small enough that `tabs`'s ordinary mix of sizes and families (nothing
exotic: a handful of `Text` widgets at a few sizes, one at
`Family::Monospace`) already exceeds one page's worth of distinct
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
glyphs landed on at least a third layer), and
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
correctly with no corruption or missing glyphs — confirming the
`copy_texture_to_texture` grow-and-relocate path and cross-layer
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
both work. Command:
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
`git checkout -- core/src/render/atlas.rs` to revert — this is a
throwaway diagnostic value, never a committed change, since a real
1024px page holding only a handful of glyphs at a time would be mostly
wasted space in normal use. Confirmed the revert left `tabs` and
`minimal` byte-identical to the pre-check screenshots afterward.
- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new
(empty) feature/limit set and run on this checkout's own emulator
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
gfxstream can't pair Venus with the real GPU here — worth remembering,
since the *default* `emu up` gives a device with **no** Vulkan adapter
at all, which reads exactly like the old bindless failure if you don't
know to ask for `EMU_GPU=software`):
cd rigs/gpu-probe
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
cargo ndk -t arm64-v8a -P 26 build --release
EMU_GPU=software emu up # from ~/repos/emulator-tools
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
adb shell chmod 755 /data/local/tmp/gpu-probe
adb shell /data/local/tmp/gpu-probe
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
`features iris requires:` (none listed — the set is empty),
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
measured working, on the exact rig that first measured it failing.
Emulator stopped afterward (`emu down`); nothing was left running.
-22
View File
@@ -5,10 +5,6 @@ one in place when it turns out to need a decision.
## App — transcript ## App — transcript
- [ ] Decide how running background tasks can be inspected. For now the session
status shows only the provider-reported count; command details stay in
their existing tool cards and must not become subagent cards.
- [ ] Messages received from other agents are inconsistent — sometimes they - [ ] Messages received from other agents are inconsistent — sometimes they
appear, sometimes they don't. **Needs a rig.** Read the code rather than 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 measured: a live Claude session only learns of a peer message from the
@@ -37,21 +33,3 @@ one in place when it turns out to need a decision.
that would work today, for Claude sessions, and it is the option that was that would work today, for Claude sessions, and it is the option that was
not chosen. not chosen.
## A session the server could not load
- [ ] **A session whose transcript will not parse is skipped with nothing but a
log line, and from the phone it looks exactly like an idle unresponsive
one.** `SessionManager::new` catches a failing `launch` and logs
"couldn't relaunch session <id>", so the session has no pump and no
driver: no status, no history, nothing sendable. That is what the
`taskNote` incident (fd71d87) looked like from Bryan's phone, and why it
needed a report from him rather than being visible in the app.
`Event::Unreadable` removes the cause that time, but not the class — an
unreadable `process.json`, a provider edited away and an unreachable host
all reach the same place.
This is the "design the unknown state first" rule: a session the server
could not load is not a session with nothing to say, and only the phone
can show the difference. It needs a status the wire can carry for it —
the failure with its reason, reported on the session itself — rather than
the reader having to tell it apart from silence.
+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)
}
File diff suppressed because it is too large. Load diff
@@ -1,9 +1,7 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -11,7 +9,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
@@ -22,7 +19,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed import com.example.wgapplink.localNetworkAllowed
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -33,39 +29,25 @@ import kotlinx.coroutines.withContext
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them. * and the back button the only other way between them.
* *
* Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none * Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
* of them a step down from another -- and what is left here is only what genuinely is a step down: * them a step down from another -- and what is left here is only what genuinely is a step down: one
* one session, spawning one, and settings. * session, spawning one, and settings.
*/ */
private sealed class Screen { private sealed class Screen {
data object Main : Screen() data object Main : Screen()
/** /**
* One session, with the file explorer or a subagent transcript over it when set. * One session, with the file explorer over it when [files] is set.
* *
* Both are layers on this screen rather than screens of their own, so the session under them * The explorer is a layer on this screen rather than a screen of its own, so the session under
* stays composed: its event stream keeps flowing, its scroll position and draft stay put, and * it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
* coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every * and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
* return, refetching the transcript over the tunnel. * created on every return, refetching the transcript over the tunnel.
*/ */
data class Session( data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
val summary: SessionSummary,
val files: FilesTarget? = null,
val subagent: SubagentSummary? = null,
) : Screen()
data object Spawn : Screen() data object Spawn : Screen()
/**
* One provider on one machine: its settings, and what its shared server is holding.
*
* A step down from the machines tab rather than a tab of its own, because it is about one
* machine rather than about the backend. Addressed by ids and names rather than by the
* [Provider] it was tapped from: what it shows is fetched, and a stale copy of a card would be
* a second version of the same truth.
*/
data class ProviderSettings(val machineId: String, val provider: String) : Screen()
data object Settings : Screen() data object Settings : Screen()
} }
@@ -196,7 +178,7 @@ fun AppRoot(
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is // deliberately does not: resizing a whole screen on every frame of the keyboard animation is
// the cost that made it lag, so it moves only its composer and transcript. // the cost that made it lag, so it moves only its composer and transcript.
when (val here = screen) { when (val here = screen) {
Screen.Main -> is Screen.Main ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
MainScreen( MainScreen(
settings = current, settings = current,
@@ -209,9 +191,6 @@ fun AppRoot(
screen = Screen.Session(imported) screen = Screen.Session(imported)
}, },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
) )
} }
is Screen.Session -> is Screen.Session ->
@@ -225,107 +204,14 @@ fun AppRoot(
// A Box so the explorer can be drawn *over* the session rather than instead of it. // A Box so the explorer can be drawn *over* the session rather than instead of it.
// No imePadding here, for the reason above -- the explorer adds its own. // No imePadding here, for the reason above -- the explorer adds its own.
Box { Box {
val fileLinkHandler = rememberFileLinkHandler { path -> SessionScreen(
screen = here.copy(files = here.summary.filesTarget(path)) settings = current,
} summary = here.summary,
Box( onBack = goToMain,
Modifier.then( onFiles = { screen = here.copy(files = it) },
if (here.subagent != null || here.files != null) share = share,
Modifier.clearAndSetSemantics {} onShareTaken = { share = null },
else Modifier )
)
) {
// How much background work the session has, from the one subscription
// to its events the screen below holds. Here because the panel and that
// screen both draw it, and must draw the same number.
var backgroundTasks by
remember(here.summary.id) {
mutableIntStateOf(here.summary.backgroundTasks)
}
// Where the panel has asked the session screen to put the reader: the
// call a background task was started by. Held here rather than inside
// either, because the two are siblings -- the panel is the one being
// tapped and the transcript is the one that can travel.
var goTo by remember(here.summary.id) { mutableStateOf<CallSite?>(null) }
// The two panels this session can be pulled aside for: its subagents
// from the right, and the whole main screen from the left. Both are here
// rather than screens of their own for the same reason the explorer is --
// the session under them stays composed. The main panel exists only
// inside a session, which is what makes it unswipeable until one has been
// opened.
SidePanels(
left = { active, close ->
MainPanel(
settings = current,
sessionId = here.summary.id,
active = active,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
onClose = close,
onGone = goToMain,
)
},
// The whole width: it stands in for the screen Back would have shown,
// rather than sitting over the session the way the subagents do.
leftFraction = 1f,
right = { active, close ->
SubagentPanel(
settings = current,
summary = here.summary,
active = active,
backgroundTasks = backgroundTasks,
onOpenSubagent = { screen = here.copy(subagent = it) },
// Closed with it: what the reader asked to see is under this
// panel, and a panel left open over the answer is the one
// thing the tap cannot have meant.
onOpenCall = {
goTo = it
close()
},
)
},
) {
CompositionLocalProvider(
LocalFileLinkHandler provides fileLinkHandler
) {
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToMain,
onFiles = { screen = here.copy(files = it) },
share = share,
onShareTaken = { share = null },
onBackgroundTasks = { backgroundTasks = it },
goTo = goTo,
onGoToTaken = { goTo = null },
)
}
}
}
here.subagent?.let { subagent ->
BackHandler { screen = here.copy(subagent = null) }
Box(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
) {
key(subagent.id) {
SessionScreen(
settings = current,
summary = here.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = subagent,
)
}
}
}
// Its own back handler is registered after this screen's, so it is the one the // Its own back handler is registered after this screen's, so it is the one the
// platform asks first, and it steps back inside itself before closing. // platform asks first, and it steps back inside itself before closing.
here.files?.let { target -> here.files?.let { target ->
@@ -337,15 +223,6 @@ fun AppRoot(
} }
} }
} }
is Screen.ProviderSettings ->
Box(Modifier.imePadding()) {
ProviderScreen(
settings = current,
machineId = here.machineId,
provider = here.provider,
onBack = goToMain,
)
}
is Screen.Spawn -> is Screen.Spawn ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
SpawnScreen( SpawnScreen(
@@ -20,6 +20,7 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -334,11 +335,12 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) {
// No Send of its own: this is one more way to answer the question, and the card's Submit is // No Send of its own: this is one more way to answer the question, and the card's Submit is
// what sends it. A second send button beside the field made the shorter half of the card look // what sends it. A second send button beside the field made the shorter half of the card look
// like the one that finishes it. // like the one that finishes it.
LabelledField( OutlinedTextField(
label = "Other",
value = text, value = text,
onValueChange = onText, onValueChange = onText,
modifier = Modifier.padding(top = 8.dp), label = { Text("Other") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) )
} }
@@ -11,30 +11,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Whether a session can be sent a picture, as the server answers it.
*
* Three states rather than a switch, because for a local model the answer belongs to the server
* that loaded it: one still coming off disk genuinely has not said. [UNKNOWN] is offered -- a
* control withheld because nobody could ask is a photo button missing from a session that would
* have read the photo perfectly well, and the send path says so if the guess was wrong.
*/
enum class ImageSupport {
ACCEPTED,
REFUSED,
UNKNOWN,
}
/**
* What the server called it; anything else -- an older server, a newer word -- is not an answer.
*/
fun imageSupport(word: String): ImageSupport =
when (word) {
"accepted" -> ImageSupport.ACCEPTED
"refused" -> ImageSupport.REFUSED
else -> ImageSupport.UNKNOWN
}
/** /**
* Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension * Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension
* from the list it writes -- rather than a file kept under its own name. Mirrors the server's * from the list it writes -- rather than a file kept under its own name. Mirrors the server's
@@ -38,11 +38,6 @@ suspend fun uploadPickedImage(
* Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage] * Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage]
* and is shrunk; anything else goes whole, under the name the other app or the file chooser gave * and is shrunk; anything else goes whole, under the name the other app or the file chooser gave
* it, because the session is told that name rather than shown the bytes. * it, because the session is told that name rather than shown the bytes.
*
* A picture is refused here, before anything is read or sent, when [images] says this session's
* model cannot read one. Here rather than beside the photo button because this is where every way
* of attaching meets: the picker, the file chooser, and another app's share sheet -- and only the
* first of those has a button to disable.
*/ */
suspend fun uploadPicked( suspend fun uploadPicked(
context: Context, context: Context,
@@ -50,16 +45,10 @@ suspend fun uploadPicked(
sessionId: String, sessionId: String,
uri: Uri, uri: Uri,
maxEdge: Int?, maxEdge: Int?,
images: ImageSupport,
): String { ): String {
val resolver = context.contentResolver val resolver = context.contentResolver
val mime = resolver.getType(uri) val mime = resolver.getType(uri)
if (mime != null && mime.startsWith("image/")) { if (mime != null && mime.startsWith("image/")) {
if (images == ImageSupport.REFUSED) {
throw ApiException(
"this session's model can't read pictures, so that one wasn't attached"
)
}
return uploadPickedImage(context, settings, sessionId, uri, maxEdge) return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
} }
// Opened before the request starts, so a provider that refuses says so here and not from inside // Opened before the request starts, so a provider that refuses says so here and not from inside
@@ -1,244 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* The background work a session has going, above its subagents in the panel [SidePanels] slides
* over it from the right.
*
* Collapsed to its one-line count by default, the way everything else this app adds to a screen
* arrives: what a reader came to the panel for is the subagents, and a run of cards about work
* nobody asked after would push them off it. Expanding pushes them down instead of covering them,
* so the two are read together.
*
* The count is drawn even when it is zero, in the same words. A section that appeared only once
* something was running made its own presence the answer, and no heading at all draws "nothing is
* running" and "nobody has asked yet" identically. There is then nothing to expand, so the heading
* carries no chevron either: it is a statement rather than a control.
*/
fun LazyListScope.backgroundTaskSection(
count: Int,
tasks: LoadState<List<BackgroundTaskSummary>?>,
expanded: Boolean,
onToggle: () -> Unit,
onRetry: () -> Unit,
onOpenCall: (CallSite) -> Unit,
) {
item(key = "background-heading") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.then(if (count == 0) Modifier else Modifier.clickable(onClick = onToggle)),
) {
Text(
"${backgroundTaskLabel(count)} running",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
if (count > 0) Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
}
// The count as well as the switch: [tasks] is the last answer anybody got, so a section left
// expanded as the work finished would draw cards for tasks that have ended.
if (count == 0 || !expanded) return
when (tasks) {
is LoadState.Loading ->
item(key = "background-loading") {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
is LoadState.Error ->
item(key = "background-error") {
Column {
Text(
tasks.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = onRetry) { Text("Try again") }
}
}
// Null is the provider declining to say, which a session whose process has gone answers.
// Said in words: the count above came from somewhere, and an empty space under it would
// read as the tasks having finished rather than as nobody being left to ask.
is LoadState.Loaded ->
when (val rows = tasks.value) {
null ->
item(key = "background-unknown") {
Text(
"This session isn't saying what these are.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
else ->
uniqueItems(rows, key = { "background-${it.id}" }) { task ->
BackgroundTaskCard(
task,
onOpen = task.call?.let { call -> { onOpenCall(call) } },
)
}
}
}
}
/**
* One background task: what it is doing, drawn as one line that says what kind it is by how it
* looks.
*
* The kind used to be a second line under the words, which on a list of backgrounded commands was
* "background command" repeated down the panel -- and for a provider that names a task by a process
* id it was the *whole* card, so every row said the same two words. A mark carries the same
* difference in a width the text does not have to make room for, and it is the [Glyph]'s
* description that keeps the words for anybody who cannot see it.
*
* A command needs no mark: drawn the way every other verbatim thing here is -- highlighted,
* monospace, on [rawSurface] -- it says "this is a command" in the same appearance the tool card it
* came from uses, and a mark beside that would be the same fact twice.
*
* [onOpen] is where the call that started this is in the transcript, for the readers who tap it:
* null where the provider never said which call it was, or where that call is no longer in the
* transcript, and the card is then a statement rather than a control. The chevron is what says
* which of the two this is, since a card that quietly does nothing when pressed is worse than one
* that never invited the press.
*/
@Composable
private fun BackgroundTaskCard(task: BackgroundTaskSummary, onOpen: (() -> Unit)?) {
val look = backgroundTaskLook(task.kind)
// Null where a provider named the task by a process id and nothing resolved a command out of
// it: there is no code to draw, so the row takes the mark and the words instead.
val command = task.description?.takeIf { look.code }
OutlinedCard(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.then(
if (onOpen == null) Modifier
else
Modifier.clickable(
onClickLabel = "Show where this started",
onClick = onOpen,
)
)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
if (command == null) {
Glyph(
look.glyph,
colour = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.semantics { contentDescription = look.words },
)
Spacer(Modifier.width(10.dp))
// The kind stands in as the words where the provider gave no description, rather
// than the id it named the task by: Codex reports a process number, which says
// nothing to the person reading and would look like a name somebody chose.
Text(
task.description ?: look.words,
style = MaterialTheme.typography.bodyMedium,
color =
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
else LocalContentColor.current,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
} else {
// Cut at its tail: what identifies a command is the program at its head, and the
// long ones are exactly the ones being read closely.
Text(
// Not cached: one command line lexes in microseconds -- the cache exists for a
// fence with two hundred lines in it.
remember(command) { highlight(command, Language.SHELL) },
style =
MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier.weight(1f)
// Smaller than the card's own radius, for the reason [RawBlock] rounds
// its corners that way: this sits inside one.
.clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface)
.padding(horizontal = 6.dp, vertical = 4.dp)
// The fill says "command" to everybody else; this says it to a reader
// who cannot see the fill.
.semantics { contentDescription = "${look.words} $command" },
)
}
if (onOpen != null) {
Spacer(Modifier.width(8.dp))
Chevron(Pointing.Right)
}
}
}
}
/**
* How one kind of background task is drawn: see [backgroundTaskLook].
*
* [code] is the kind whose description is verbatim text rather than prose, which is drawn as code
* and takes no [glyph]; the glyph is still what a task of that kind falls back to when nothing said
* what it ran.
*/
private data class TaskLook(val glyph: String, val words: String, val code: Boolean)
/**
* Everything a [BackgroundTaskSummary.kind] decides, answered by one `when`.
*
* One rather than three, which is the rule this screen already learned once with the status word
* and its colour: three `when`s over one set is two of them waiting to miss a member.
*
* A kind this build has not heard of takes the question mark and is named by what every one of them
* has in common. The nearest word or mark we do know -- a robot, a terminal -- would be this screen
* deciding what the server meant by a word it invented after this build shipped.
*/
private fun backgroundTaskLook(kind: String) =
when (kind) {
// A command is drawn in the face a command is drawn in everywhere else here.
"command" -> TaskLook(COMMAND_GLYPH, "background command", code = true)
"agent" -> TaskLook(AGENT_GLYPH, "subagent", code = false)
"workflow" -> TaskLook(WORKFLOW_GLYPH, "workflow", code = false)
else -> TaskLook(UNKNOWN_GLYPH, "background task", code = false)
}
/**
* The heading over one group in the panel, so neither list is a run of cards with no name.
*
* The same band as the background section's own heading row above, rather than a gap chosen to look
* right here: what separates a heading from the cards above it is that both headings sit in a row
* of one height.
*/
@Composable
fun PanelSectionHeading(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 48.dp)) {
Text(text, style = MaterialTheme.typography.titleMedium)
}
}
@@ -1,15 +1,10 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -54,49 +49,3 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
* ends that tall would bow its sides. * ends that tall would bow its sides.
*/ */
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp) val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
/**
* A round button sized to the mark it draws.
*
* The composer's three actions -- attach, stop, send -- are single glyphs, and a pill's word-shaped
* padding around one glyph was width taken from the pickers beside it: with a long model name on
* the row, the permission mode ended up too small to hit. One diameter for all three, and it is the
* platform's minimum touch target rather than a button's shorter default height.
*
* [fill] null draws the outlined form, for the one of the three that does not act on the session.
*/
@Composable
fun CircleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
fill: Color? = null,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
val sized = modifier.size(CircleButtonSize)
if (fill == null) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = sized,
) {
content()
}
} else {
Button(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
colors = actionButtonColors(fill),
contentPadding = PaddingValues(0.dp),
modifier = sized,
) {
content()
}
}
}
/** How wide and tall one of those is; see [CircleButton]. */
val CircleButtonSize = 48.dp
@@ -160,7 +160,6 @@ private val FENCE_LANGUAGES: Map<String, Language> =
"shell" to Language.SHELL, "shell" to Language.SHELL,
"zsh" to Language.SHELL, "zsh" to Language.SHELL,
"console" to Language.SHELL, "console" to Language.SHELL,
"diff" to Language.DIFF,
"python" to Language.PYTHON, "python" to Language.PYTHON,
"py" to Language.PYTHON, "py" to Language.PYTHON,
"javascript" to Language.JAVASCRIPT, "javascript" to Language.JAVASCRIPT,
@@ -60,23 +60,3 @@ fun compactingLabel(seconds: Long?): String =
seconds < 60 -> "compacting ${seconds}s" seconds < 60 -> "compacting ${seconds}s"
else -> "compacting ${seconds / 60}m ${seconds % 60}s" else -> "compacting ${seconds / 60}m ${seconds % 60}s"
} }
/**
* How full the session is, as the status row says it.
*
* Three states, not two, and the third is the one that needed the words: a session whose occupancy
* is known and whose ceiling is not. That one keeps the bare figure, and a session with a ceiling
* gets both the reader can see which they are looking at. What must not happen is a missing
* ceiling drawn as a number, or as a proportion of some assumed window, which would be this screen
* inventing the very fact it does not have.
*
* A llama.cpp session always has one, since the window is a flag its own server was started with. A
* coding CLI's is the vendor's business and neither control protocol states it, so those keep the
* bare figure they have always had.
*/
fun contextLabel(held: Long?, limit: Long?): String =
when {
held == null -> "context unknown"
limit == null -> "context ${tokens(held)}"
else -> "context ${tokens(held)} / ${tokens(limit)}"
}
@@ -12,10 +12,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp 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. * A line across the transcript saying what left the session's context.
@@ -40,28 +36,6 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
} }
} }
/**
* The rule between two replies that met with nothing said in between -- see
* [TranscriptItem.TurnBreak].
*
* No words and no colour. Every other divider here reports something that happened and is worth
* finding by scanning; this one only says "these are two", and it appears once per turn that
* started without anybody typing. Saying more was a screenful of announcements about background
* work the reader was not asking after -- one of them a whole shell command, drawn as centred prose
* because the words came from somewhere that had no reason to keep them short.
*
* The outline colour is the scheme's one for structure rather than for meaning, which is what this
* is. Inset from both edges so it reads as a separator between two rows rather than as the top edge
* of the one under it.
*/
@Composable
fun TurnBreakRow(modifier: Modifier = Modifier) {
HorizontalDivider(
modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 6.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
/** /**
* The mark a clear leaves. * The mark a clear leaves.
* *
@@ -73,40 +47,3 @@ fun TurnBreakRow(modifier: Modifier = Modifier) {
fun ClearedRow(modifier: Modifier = Modifier) { fun ClearedRow(modifier: Modifier = Modifier) {
TranscriptDivider("Context cleared", clearedColor, 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"
}
@@ -14,7 +14,7 @@ private const val RESET_EVENT = "reset"
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it * mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor. * saw as the new cursor.
*/ */
class EventStream(settings: ServerSettings, private val address: TranscriptAddress) { class EventStream(settings: ServerSettings, private val sessionId: String) {
private val stream = Sse(settings) private val stream = Sse(settings)
fun close() = stream.close() fun close() = stream.close()
@@ -35,7 +35,7 @@ class EventStream(settings: ServerSettings, private val address: TranscriptAddre
// one and the screen folds the other, and they have to be the same line. // one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit, onEvent: (raw: String, event: SeqEvent) -> Unit,
) { ) {
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data -> stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name. // A named frame carries no payload and a data frame has no name.
if (name == RESET_EVENT) onReset() if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data)) else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
@@ -61,24 +61,6 @@ sealed class SessionEvent {
data class AssistantText(val delta: String) : SessionEvent() data class AssistantText(val delta: String) : SessionEvent()
/** The durable value of the open assistant message, replacing its provisional deltas. */
data class AssistantTextFinal(val text: String) : SessionEvent()
/**
* The model's working, streamed the way its reply is: its own card, and deliberately not part
* of what the session said. Only a provider that actually streams its reasoning sends it.
*/
data class Thinking(val delta: String) : SessionEvent()
/**
* The thinking above this finished, having taken [ms].
*
* Measured by the driver, because only it can see when the model stopped: this app knows when
* an event *arrived*, and the last fragment of a block followed by a slow tool call looks
* exactly like thinking that went on that long.
*/
data class ThinkingDone(val ms: Long) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent() data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
data class ToolUpdate(val id: String, val output: String) : SessionEvent() data class ToolUpdate(val id: String, val output: String) : SessionEvent()
@@ -126,26 +108,6 @@ sealed class SessionEvent {
val turnStart: Long? = null, val turnStart: Long? = null,
) : SessionEvent() ) : SessionEvent()
/**
* A line in the transcript this build cannot read: a kind a newer server wrote, or one an older
* server wrote that has since been dropped.
*
* [kind] is the word the line called itself, so the row can say what is missing rather than
* that something is. The server makes these when reading; no driver sends one.
*/
data class Unreadable(val kind: String) : SessionEvent()
/**
* Retired on 2026-09-06, hours after it was added: a background task finishing, which turned
* out to be a screenful of notices about work nobody was asking after.
*
* Kept because a transcript is append-only -- the sessions that ran a background task in that
* window have these lines for ever. It draws no row, which is the whole reason it is still
* named here rather than left to fall through to [Unknown]: that would draw a placeholder per
* background task, which is the same wall the row was removed for.
*/
object RetiredTaskNote : SessionEvent()
/** /**
* A command the session was asked to run on itself and cannot run yet. Resolved by * A command the session was asked to run on itself and cannot run yet. Resolved by
* [CommandSent] with the same id; a command that ran straight away has only that one. * [CommandSent] with the same id; a command that ran straight away has only that one.
@@ -155,9 +117,6 @@ sealed class SessionEvent {
/** The same command, handed to the session. */ /** The same command, handed to the session. */
data class CommandSent(val id: String, val text: String) : SessionEvent() data class CommandSent(val id: String, val text: String) : SessionEvent()
/** Provider-reported number of background tasks alive now. */
data class BackgroundTasks(val count: Int) : SessionEvent()
data class Status(val state: String) : SessionEvent() data class Status(val state: String) : SessionEvent()
/** /**
@@ -168,16 +127,6 @@ sealed class SessionEvent {
*/ */
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent() data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/**
* Whether a picture can be sent to this session now, as the thing serving its model answered.
*
* Only a llama.cpp session says this, and it says it twice per model: unknown the moment the
* old one is left, then the loaded server's answer. It carries no row -- it is what the
* composer's photo button is drawn from, and a line in the transcript about a control is not
* something anybody asked after.
*/
data class Images(val images: ImageSupport) : SessionEvent()
/** /**
* What a turn cost, and how much the model was holding when it ended. * What a turn cost, and how much the model was holding when it ended.
* *
@@ -186,25 +135,7 @@ sealed class SessionEvent {
* so adding turns up would report a figure the session stopped being true of. Null where the * so adding turns up would report a figure the session stopped being true of. Null where the
* dialect did not say, which leaves the context unmeasured rather than unchanged. * dialect did not say, which leaves the context unmeasured rather than unchanged.
*/ */
data class UsageDelta( data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
val tokens: Long,
val context: Long?,
/**
* How fast the reply came out, where the provider measured it -- null everywhere else,
* which is most of them. Never worked out here: the time this app watched a reply arrive
* over includes the network and whatever the server was doing between tokens.
*/
val tokensPerSecond: Double? = null,
/**
* How long the provider spent reading the prompt before it began answering; null where
* nothing measured it. The same rule as [tokensPerSecond]: the provider's own figure, or
* nothing at all.
*/
val prefillMs: Long? = null,
) : SessionEvent()
/** How much context this session's model has, which is what [UsageDelta.context] is out of. */
data class ContextWindow(val tokens: Long) : SessionEvent()
/** /**
* A compaction that finished, and how much context it recovered. * A compaction that finished, and how much context it recovered.
@@ -226,20 +157,6 @@ sealed class SessionEvent {
*/ */
data object Cleared : SessionEvent() 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 AuthenticationRequired(val message: String) : SessionEvent()
data class Error(val message: String) : SessionEvent() data class Error(val message: String) : SessionEvent()
/** /**
@@ -276,9 +193,6 @@ fun parseSeqEvent(json: String): SeqEvent {
) )
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id")) "messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta")) "assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"assistantTextFinal" -> SessionEvent.AssistantTextFinal(body.getString("text"))
"thinking" -> SessionEvent.Thinking(body.getString("delta"))
"thinkingDone" -> SessionEvent.ThinkingDone(body.getLong("ms"))
"toolStart" -> "toolStart" ->
SessionEvent.ToolStart( SessionEvent.ToolStart(
id = body.getString("id"), id = body.getString("id"),
@@ -326,26 +240,19 @@ fun parseSeqEvent(json: String): SeqEvent {
body.getString("text"), body.getString("text"),
if (body.has("turnStart")) body.getLong("turnStart") else null, if (body.has("turnStart")) body.getLong("turnStart") else null,
) )
"unreadable" -> SessionEvent.Unreadable(body.getString("kind"))
"taskNote" -> SessionEvent.RetiredTaskNote
"commandQueued" -> "commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text")) SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text")) "commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
"backgroundTasks" -> SessionEvent.BackgroundTasks(body.getInt("count"))
"status" -> SessionEvent.Status(body.getString("state")) "status" -> SessionEvent.Status(body.getString("state"))
"settings" -> "settings" ->
SessionEvent.Settings( SessionEvent.Settings(
model = body.optString("model").ifEmpty { null }, model = body.optString("model").ifEmpty { null },
permissionMode = body.optString("permissionMode").ifEmpty { null }, permissionMode = body.optString("permissionMode").ifEmpty { null },
) )
"images" -> SessionEvent.Images(imageSupport(body.optString("images")))
"contextWindow" -> SessionEvent.ContextWindow(body.getLong("tokens"))
"usageDelta" -> "usageDelta" ->
SessionEvent.UsageDelta( SessionEvent.UsageDelta(
body.getLong("tokens"), body.getLong("tokens"),
if (body.has("context")) body.getLong("context") else null, if (body.has("context")) body.getLong("context") else null,
if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null,
if (body.has("prefillMs")) body.getLong("prefillMs") else null,
) )
"compacted" -> "compacted" ->
SessionEvent.Compacted( SessionEvent.Compacted(
@@ -354,12 +261,6 @@ fun parseSeqEvent(json: String): SeqEvent {
trigger = body.optString("trigger").ifEmpty { null }, trigger = body.optString("trigger").ifEmpty { null },
) )
"cleared" -> SessionEvent.Cleared "cleared" -> SessionEvent.Cleared
"limitReached" ->
SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
)
"authenticationRequired" ->
SessionEvent.AuthenticationRequired(body.getString("message"))
"error" -> SessionEvent.Error(body.getString("message")) "error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type) else -> SessionEvent.Unknown(type)
} }
@@ -374,21 +275,7 @@ fun parseSeqEvent(json: String): SeqEvent {
* first time the server grows a state, and the drift would be a reply that never splits or one * first time the server grows a state, and the drift would be a reply that never splits or one
* split mid-stream. * split mid-stream.
*/ */
fun sessionWorking(state: String): Boolean = fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
state == "running" || state == "compacting" || state == "loading" || state == "reading"
/** Whether the latest events still say this session needs an explicit provider login. */
internal fun authenticationPromptAfter(open: Boolean, event: SessionEvent): Boolean =
when (event) {
is SessionEvent.AuthenticationRequired -> true
// A later provider response proves an older authentication failure in a replayed page is
// no longer current. Without this, one old failure reopened sign-in after every later
// successful turn.
is SessionEvent.AssistantText,
is SessionEvent.AssistantTextFinal,
is SessionEvent.ToolStart -> false
else -> open
}
/** /**
* The context after [event], given what it was before. * The context after [event], given what it was before.
@@ -412,18 +299,3 @@ fun contextAfter(current: Long?, event: SessionEvent): Long? =
is SessionEvent.Cleared -> null is SessionEvent.Cleared -> null
else -> current else -> current
} }
/**
* The context window after [event], mirroring the server's `context_limit_after` for the same
* reason [contextAfter] mirrors its neighbour: the screen has to keep up between page loads.
*
* A window belongs to the process, so a session whose process has exited has none left standing,
* a session restarted on a different model would draw its occupancy against the old model's
* ceiling.
*/
fun contextLimitAfter(current: Long?, event: SessionEvent): Long? =
when (event) {
is SessionEvent.ContextWindow -> event.tokens
is SessionEvent.Status -> if (event.state == "exited") null else current
else -> current
}
@@ -1,124 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
/**
* A text field whose label is a line above it rather than a thing floating inside it.
*
* Every field in this app goes through here, and the reason is vertical space. Material's outlined
* field reserves room for a label that animates into its own border and pads the value by half a
* line top and bottom, so one setting costs the height of three lines of text to hold one. A form
* of ten settings is then a screen and a half of scrolling to read ten short answers.
*
* What is *not* shrunk is the value itself: it stays at body size, because what is expensive here
* is the framing rather than the text, and a field whose contents are smaller than the text beside
* it is a field the reader has to lean in to check. See UI_RULES on never shrinking text to fit.
*
* [hint] is what leaving it blank means, and it goes above the box with the label for the same
* reason the label does: inside, it is gone the moment anybody types, which is exactly when a
* reader looks back to check what they are overriding.
*/
@Composable
fun LabelledField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
hint: String? = null,
enabled: Boolean = true,
/**
* How many lines the box is, at rest. One for a value; several for prose, where the reader is
* writing rather than filling in -- see `ParamKind::Prose`.
*/
lines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
/** What the keyboard's own action key does, which is usually what the button beside it does. */
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
Column(modifier.fillMaxWidth()) {
// Body size in the ordinary text colour, which is what a setting's label is where the
// control beside it is a switch or a picker. Smaller and greyer on the ones that are
// fields reads as two ranks of setting where there is one.
Text(label, modifier = Modifier.padding(bottom = 2.dp))
hint?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 2.dp),
)
}
FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions)
}
}
/** The box itself: the border, the padding, and the text. Shared so the two fields agree. */
@Composable
private fun FieldBox(
value: String,
onValueChange: (String) -> Unit,
enabled: Boolean,
lines: Int,
keyboardOptions: KeyboardOptions,
keyboardActions: KeyboardActions,
) {
val interactions = remember { MutableInteractionSource() }
val focused by interactions.collectIsFocusedAsState()
// The focused border is the accent at the same width as the resting one. Growing it instead
// would move the text inside by a pixel on every focus, which is a whole form twitching as the
// reader moves down it.
val edge =
when {
!enabled -> MaterialTheme.colorScheme.outlineVariant
focused -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.outline
}
val shape = RoundedCornerShape(8.dp)
val style =
LocalTextStyle.current.merge(
TextStyle(
color =
if (enabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant
)
)
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
singleLine = lines == 1,
minLines = lines,
textStyle = style,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactions,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier =
Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest, shape)
.border(1.dp, edge, shape)
.padding(horizontal = 10.dp, vertical = 8.dp),
decorationBox = { field -> Box { field() } },
)
}
@@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -43,60 +44,26 @@ import kotlinx.coroutines.withContext
/** /**
* Which machine's files to show, and where to start. * Which machine's files to show, and where to start.
* *
* A **machine**, not a session: a filesystem is a property of a machine, and a session only says * A **setup**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the machines tab -- one more * where it was working. That is what makes a second way in -- from the setups tab -- one more
* caller rather than any new code here. * caller rather than any new code here.
*/ */
data class FilesTarget( data class FilesTarget(val setup: String, val setupName: String, val start: String)
val machine: String,
val machineName: String,
val start: String,
/** A document to open immediately; [start] remains the fallback directory. */
val file: String? = null,
)
/**
* The explorer opened on one file, wherever that file is.
*
* Its directory is what the reader lands in on the way back, which for a session's transcript is
* that session's own directory -- the log, the process record and the rest of what it wrote.
*/
fun fileTarget(file: FileOnMachine) =
FilesTarget(
machine = file.machine,
machineName = file.machineName,
start = parentOf(file.path) ?: "/",
file = file.path,
)
/** The explorer target for this session's machine, optionally opened on [file]. */
fun SessionSummary.filesTarget(file: String? = null) =
FilesTarget(
machine = machine,
machineName = machineName,
start = cwd?.takeIf { it.isNotBlank() } ?: "~",
file = file,
)
/** Where the explorer is: in a directory, or in one file. */ /** Where the explorer is: in a directory, or in one file. */
private sealed class Spot(val path: String) { private sealed class Spot(val path: String) {
class Dir(path: String) : Spot(path) class Dir(path: String) : Spot(path)
class Doc(path: String, val directory: Dir) : Spot(path) class Doc(path: String) : Spot(path)
}
private enum class UnsavedDestination {
Directory,
Session,
} }
/** /**
* The files on the machine a session runs on: browse them, read one, change one. * The files on the machine a session runs on: browse them, read one, change one.
* *
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps * Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing and coming back from a file costs nothing. Both back controls return from a file to its * flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
* directory. In a directory, Android back walks toward the session's project directory and closes * viewer, viewer to the directory it came from, directory to the one above -- and only closes from
* the explorer once it gets there; the header's back button closes it immediately. * where it opened.
* *
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is * Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* how one gets asked again on purpose, and creating something refetches the directory it was * how one gets asked again on purpose, and creating something refetches the directory it was
@@ -105,82 +72,51 @@ private enum class UnsavedDestination {
@Composable @Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val initialDirectory = var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
target.file?.let(::parentOf)?.let { Spot.Dir(it) } ?: Spot.Dir(target.start)
var here by
remember(target) {
mutableStateOf<Spot>(
target.file?.let { Spot.Doc(it, initialDirectory) } ?: initialDirectory
)
}
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() } val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) } var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below, // Edit mode and whether anything has been typed live here rather than in the pane below,
// because both ways out have to ask before discarding it. // because they are what back has to know about -- and back arrives from two places, the arrow
// and the platform's own gesture, which must mean the same thing.
var editing by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(false) }
var dirty by remember { mutableStateOf(false) } var dirty by remember { mutableStateOf(false) }
var unsavedDestination by remember { mutableStateOf<UnsavedDestination?>(null) } var askUnsaved by remember { mutableStateOf(false) }
val here = stack.last()
fun go(spot: Spot) { fun go(spot: Spot) {
editing = false editing = false
dirty = false dirty = false
here = spot stack = stack + spot
} }
fun leave(destination: UnsavedDestination) { fun back() {
if (editing && dirty) { when {
unsavedDestination = destination editing && dirty -> askUnsaved = true
} else if (destination == UnsavedDestination.Directory) { editing -> editing = false
go((here as Spot.Doc).directory) stack.size > 1 -> {
} else { stack = stack.dropLast(1)
onClose() editing = false
dirty = false
}
else -> onClose()
} }
} }
suspend fun load(path: String, again: Boolean) { suspend fun load(path: String, again: Boolean) {
val existing = listings[path] if (!again && listings[path] is LoadState.Loaded) return
if (!again && (existing is LoadState.Loaded || existing is LoadState.Loading)) return
listings[path] = LoadState.Loading listings[path] = LoadState.Loading
listings[path] = listings[path] =
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.machine, path)) LoadState.Loaded(fetchDir(settings, target.setup, path))
} }
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
} }
val projectDirectory = (listings[target.start] as? LoadState.Loaded)?.value?.path BackHandler(onBack = ::back)
val homeDirectory =
if (target.start == "~") projectDirectory
else (listings["~"] as? LoadState.Loaded)?.value?.path
fun systemBack() {
when (val spot = here) {
is Spot.Doc -> leave(UnsavedDestination.Directory)
is Spot.Dir -> {
val path = (listings[spot.path] as? LoadState.Loaded)?.value?.path ?: spot.path
when {
path == projectDirectory || path == target.start -> onClose()
projectDirectory != null ->
nextDirectoryToward(path, projectDirectory)?.let { go(Spot.Dir(it)) }
?: onClose()
else -> parentOf(path)?.let { go(Spot.Dir(it)) } ?: onClose()
}
}
}
}
// A file link can open without visiting the project first, but Back still needs to know where
// the project is. Home is likewise resolved by the machine rather than guessed on the phone;
// it is what lets every path beneath it be displayed with `~`, including over ssh.
LaunchedEffect(target.machine, target.start) {
if (target.file != null) load(target.start, again = false)
if (target.start != "~") load("~", again = false)
}
BackHandler(onBack = ::systemBack)
Box( Box(
Modifier.fillMaxSize() Modifier.fillMaxSize()
@@ -193,15 +129,14 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
when (val spot = here) { when (val spot = here) {
is Spot.Dir -> { is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading val state = listings[spot.path] ?: LoadState.Loading
// Navigate with the resolved path, but name anything under the machine's home // The resolved path once there is one: a directory opened as `~` is called what
// the way somebody working there would write it. // it turned out to be, not what it was asked for.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
val shownAt = tildePath(at, homeDirectory)
FilesHeader( FilesHeader(
title = baseName(shownAt), title = baseName(at),
path = shownAt, path = at,
machine = target.machineName, machine = target.setupName,
onBack = { leave(UnsavedDestination.Session) }, onBack = ::back,
) { ) {
GlyphButton( GlyphButton(
REFRESH_GLYPH, REFRESH_GLYPH,
@@ -217,7 +152,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
) )
} }
LaunchedEffect(spot.path) { load(spot.path, again = false) } LaunchedEffect(spot.path) { load(spot.path, again = false) }
DirectoryBody(state, directory = spot, onOpen = ::go) DirectoryBody(state, onOpen = ::go)
} }
is Spot.Doc -> is Spot.Doc ->
DocPane( DocPane(
@@ -226,26 +161,22 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
path = spot.path, path = spot.path,
name = baseName(spot.path), name = baseName(spot.path),
editing = editing, editing = editing,
homeDirectory = homeDirectory,
onEditing = { editing = it }, onEditing = { editing = it },
onDirty = { dirty = it }, onDirty = { dirty = it },
onBack = { leave(UnsavedDestination.Directory) }, onBack = ::back,
) )
} }
} }
} }
unsavedDestination?.let { destination -> if (askUnsaved) {
UnsavedDialog( UnsavedDialog(
onDiscard = { onDiscard = {
unsavedDestination = null askUnsaved = false
if (destination == UnsavedDestination.Directory) { editing = false
go((here as Spot.Doc).directory) dirty = false
} else {
onClose()
}
}, },
onCancel = { unsavedDestination = null }, onCancel = { askUnsaved = false },
) )
} }
@@ -254,7 +185,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
if (creating && dir != null && listing != null) { if (creating && dir != null && listing != null) {
CreateDialog( CreateDialog(
settings = settings, settings = settings,
machine = target.machine, setup = target.setup,
directory = listing.path, directory = listing.path,
onDismiss = { creating = false }, onDismiss = { creating = false },
onCreated = { path, isDirectory -> onCreated = { path, isDirectory ->
@@ -265,7 +196,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
load(dir.path, again = true) load(dir.path, again = true)
// A new file has nothing to look at, so it opens where it can be filled in. // A new file has nothing to look at, so it opens where it can be filled in.
if (!isDirectory) { if (!isDirectory) {
go(Spot.Doc(path, dir)) go(Spot.Doc(path))
editing = true editing = true
} }
} }
@@ -317,11 +248,7 @@ private fun FilesHeader(
* looks like a right one. * looks like a right one.
*/ */
@Composable @Composable
private fun ColumnScope.DirectoryBody( private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
state: LoadState<Listing>,
directory: Spot.Dir,
onOpen: (Spot) -> Unit,
) {
when (state) { when (state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp)) is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
is LoadState.Error -> is LoadState.Error ->
@@ -362,9 +289,7 @@ private fun ColumnScope.DirectoryBody(
name = entry.name, name = entry.name,
trailing = trailingOf(entry), trailing = trailingOf(entry),
onClick = { onClick = {
onOpen( onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path, directory)
)
}, },
) )
} }
@@ -432,7 +357,6 @@ private fun ColumnScope.DocPane(
path: String, path: String,
name: String, name: String,
editing: Boolean, editing: Boolean,
homeDirectory: String?,
onEditing: (Boolean) -> Unit, onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit, onDirty: (Boolean) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
@@ -457,7 +381,7 @@ private fun ColumnScope.DocPane(
state = LoadState.Loading state = LoadState.Loading
state = state =
try { try {
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.machine, path) } val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
if (got is FileContent.Text) draft = TextFieldValue(got.content) if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got) LoadState.Loaded(got)
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -480,7 +404,7 @@ private fun ColumnScope.DocPane(
try { try {
val written = val written =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
writeFile(settings, target.machine, path, draft.text, against) writeFile(settings, target.setup, path, draft.text, against)
} }
state = state =
LoadState.Loaded( LoadState.Loaded(
@@ -506,12 +430,7 @@ private fun ColumnScope.DocPane(
} }
} }
FilesHeader( FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
title = name,
path = tildePath(path, homeDirectory),
machine = target.machineName,
onBack = onBack,
) {
if (editing) { if (editing) {
if (saving) { if (saving) {
GlyphSpinner("Saving") GlyphSpinner("Saving")
@@ -608,9 +527,7 @@ private fun ColumnScope.DocPane(
scope.launch { scope.launch {
val fresh = val fresh =
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
fetchFile(settings, target.machine, path)
}
} catch (e: ApiException) { } catch (e: ApiException) {
saveError = e.message saveError = e.message
conflict = null conflict = null
@@ -654,7 +571,7 @@ private fun Note(text: String) {
@Composable @Composable
private fun CreateDialog( private fun CreateDialog(
settings: ServerSettings, settings: ServerSettings,
machine: String, setup: String,
directory: String, directory: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit, onCreated: (String, Boolean) -> Unit,
@@ -674,8 +591,8 @@ private fun CreateDialog(
scope.launch { scope.launch {
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, machine, path) if (isDirectory) createDir(settings, setup, path)
else createFile(settings, machine, path) else createFile(settings, setup, path)
} }
onCreated(path, isDirectory) onCreated(path, isDirectory)
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -692,11 +609,13 @@ private fun CreateDialog(
title = { Text("Create in ${baseName(directory)}") }, title = { Text("Create in ${baseName(directory)}") },
text = { text = {
Column { Column {
LabelledField( OutlinedTextField(
label = "Name",
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !busy, enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -759,35 +678,6 @@ internal fun parentOf(path: String): String? {
} }
} }
/**
* The next directory on the filesystem path from [current] to [destination], or null when there.
*
* Moving between two branches first walks upward to their common ancestor. Once [current] is that
* ancestor, the next press walks one segment down toward [destination]. Both paths are answers from
* the machine, so they are absolute and have no symlinks or `..` left to resolve here.
*/
internal fun nextDirectoryToward(current: String, destination: String): String? {
val here = current.trimEnd('/').ifEmpty { "/" }
val there = destination.trimEnd('/').ifEmpty { "/" }
if (here == there) return null
val beneathHere = if (here == "/") there.startsWith('/') else there.startsWith("$here/")
if (!beneathHere) return parentOf(here)
val next = there.removePrefix(here).trimStart('/').substringBefore('/')
return join(here, next)
}
/** A path as somebody on [home] writes it, leaving paths outside that home unchanged. */
internal fun tildePath(path: String, home: String?): String {
val at = path.trimEnd('/').ifEmpty { "/" }
val resolvedHome = home?.trimEnd('/')?.ifEmpty { "/" } ?: return at
return when {
at == resolvedHome -> "~"
resolvedHome != "/" && at.startsWith("$resolvedHome/") ->
"~${at.removePrefix(resolvedHome)}"
else -> at
}
}
/** What a path names: its last segment, with `/` naming itself. */ /** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String { internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/') val trimmed = path.trimEnd('/')
@@ -7,8 +7,6 @@ import androidx.compose.ui.text.buildAnnotatedString
/** What a span of code is, in the terms the palette has a colour for. */ /** What a span of code is, in the terms the palette has a colour for. */
enum class Kind { enum class Kind {
ADDITION,
DELETION,
KEYWORD, KEYWORD,
STRING, STRING,
LITERAL, LITERAL,
@@ -26,8 +24,6 @@ data class Span(val start: Int, val end: Int, val kind: Kind)
* one instance and lives with the rest of the palette. * one instance and lives with the rest of the palette.
*/ */
data class SyntaxPalette( data class SyntaxPalette(
val addition: Color,
val deletion: Color,
val keyword: Color, val keyword: Color,
val string: Color, val string: Color,
val literal: Color, val literal: Color,
@@ -38,8 +34,6 @@ data class SyntaxPalette(
) { ) {
fun of(kind: Kind): Color = fun of(kind: Kind): Color =
when (kind) { when (kind) {
Kind.ADDITION -> addition
Kind.DELETION -> deletion
Kind.KEYWORD -> keyword Kind.KEYWORD -> keyword
Kind.STRING -> string Kind.STRING -> string
Kind.LITERAL -> literal Kind.LITERAL -> literal
@@ -50,26 +44,6 @@ data class SyntaxPalette(
} }
} }
/** A unified diff is line-oriented: colour the changed lines and leave context untouched. */
fun scanDiff(code: String): List<Span> {
val spans = ArrayList<Span>()
var start = 0
while (start < code.length) {
val end = code.indexOf('\n', start).let { if (it == -1) code.length else it }
val kind =
when {
code.startsWith("+++", start) || code.startsWith("---", start) -> Kind.METADATA
code.startsWith("+", start) -> Kind.ADDITION
code.startsWith("-", start) -> Kind.DELETION
code.startsWith("@@", start) -> Kind.METADATA
else -> null
}
if (kind != null) spans.add(Span(start, end, kind))
start = if (end == code.length) end else end + 1
}
return spans
}
/** /**
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it. * [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
* *
@@ -82,8 +82,8 @@ private const val SETTLE_MS = 500L
@Composable @Composable
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) { fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var machines by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) } var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Machine?>(null) } var chosen by remember { mutableStateOf<Setup?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) } var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// What is happening to each row right now, as the word the row shows. A map keyed by id rather // What is happening to each row right now, as the word the row shows. A map keyed by id rather
@@ -100,8 +100,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
// themselves, not a flag, so the dialog can say what it is about. // themselves, not a flag, so the dialog can say what it is about.
var confirming by remember { mutableStateOf<List<Importable>?>(null) } var confirming by remember { mutableStateOf<List<Importable>?>(null) }
// Set from the selected Claude provider rather than repeated in the app. // Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
var permissionMode by remember { mutableStateOf("") } // times.
var permissionMode by remember { mutableStateOf("auto") }
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it, // When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
// so a tap reading it needs no recomposition. // so a tap reading it needs no recomposition.
val movedAt = remember { mutableMapOf<String, Long>() } val movedAt = remember { mutableMapOf<String, Long>() }
@@ -113,9 +114,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Taken from the answer rather than kept across the load: the server is what knows what is * Taken from the answer rather than kept across the load: the server is what knows what is
* running, and this screen may be opening on work another phone started. * running, and this screen may be opening on work another phone started.
*/ */
suspend fun fetchInto(machine: Machine): LoadState<List<Importable>> = suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
try { try {
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, machine.id) } val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap() running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap() rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
LoadState.Loaded(rows) LoadState.Loaded(rows)
@@ -123,10 +124,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
LoadState.Error(err.message ?: "Couldn't list sessions") LoadState.Error(err.message ?: "Couldn't list sessions")
} }
fun loadSessions(machine: Machine) { fun loadSessions(setup: Setup) {
sessions = LoadState.Loading sessions = LoadState.Loading
selected = emptySet() selected = emptySet()
scope.launch { sessions = fetchInto(machine) } scope.launch { sessions = fetchInto(setup) }
} }
/** Takes a row out of the list, once the machine no longer has it to offer. */ /** Takes a row out of the list, once the machine no longer has it to offer. */
@@ -140,9 +141,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} }
LaunchedEffect(reloadToken) { LaunchedEffect(reloadToken) {
machines = setups =
try { try {
val found = withContext(Dispatchers.IO) { fetchMachines(settings) } val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
found.firstOrNull()?.let { found.firstOrNull()?.let {
chosen = it chosen = it
loadSessions(it) loadSessions(it)
@@ -170,7 +171,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
selected = emptySet() selected = emptySet()
running = running + targets.associate { it.id to WAITING } running = running + targets.associate { it.id to WAITING }
rowErrors = rowErrors - targets.map { it.id }.toSet() rowErrors = rowErrors - targets.map { it.id }.toSet()
val machine = chosen val setup = chosen
val ids = targets.map { it.id } val ids = targets.map { it.id }
scope.launch { scope.launch {
// One request for the whole batch, not one per row. Sent row by row, a handover was // One request for the whole batch, not one per row. Sent row by row, a handover was
@@ -197,28 +198,25 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// The listing is the repair, because it carries the same state the events do. Only when // The listing is the repair, because it carries the same state the events do. Only when
// something still looks outstanding, so the ordinary case does not pay for a second // something still looks outstanding, so the ordinary case does not pay for a second
// listing, which is the most expensive call this screen makes. // listing, which is the most expensive call this screen makes.
if (machine != null && targets.any { running.containsKey(it.id) }) { if (setup != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are already // Quietly: no Loading, because blanking the list to report on rows that are already
// saying what is happening to them is the flicker this screen avoids everywhere // saying what is happening to them is the flicker this screen avoids everywhere
// else. // else.
sessions = fetchInto(machine) sessions = fetchInto(setup)
} }
} }
} }
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
LaunchedEffect(chosen?.id, provider?.name) {
permissionMode = provider?.defaultPermissionMode.orEmpty()
}
/** Continues [targets] in the background, leaving the screen where it is. */ /** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) { fun importAll(targets: List<Importable>) {
val machine = chosen ?: return val setup = chosen ?: return
val useProvider = provider ?: return val useProvider = provider ?: return
handOver(targets) { ids -> handOver(targets) { ids ->
startImport( startImport(
settings, settings,
machine = machine.id, setup = setup.id,
sessionIds = ids, sessionIds = ids,
provider = useProvider.name, provider = useProvider.name,
permissionMode = permissionMode, permissionMode = permissionMode,
@@ -234,7 +232,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* it, which is the case where waiting is the right thing anyway. * it, which is the case where waiting is the right thing anyway.
*/ */
fun importAndOpen(target: Importable) { fun importAndOpen(target: Importable) {
val machine = chosen ?: return val setup = chosen ?: return
val useProvider = provider ?: return val useProvider = provider ?: return
running = running + (target.id to IMPORTING) running = running + (target.id to IMPORTING)
rowErrors = rowErrors - target.id rowErrors = rowErrors - target.id
@@ -244,7 +242,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
spawnSession( spawnSession(
settings, settings,
machine = machine.id, setup = setup.id,
provider = useProvider.name, provider = useProvider.name,
// Nothing to say: the server titles it from the session it continues. // Nothing to say: the server titles it from the session it continues.
title = "", title = "",
@@ -272,10 +270,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null) java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
} }
LaunchedEffect(chosen?.id) { LaunchedEffect(chosen?.id) {
val machine = chosen?.id ?: return@LaunchedEffect val setup = chosen?.id ?: return@LaunchedEffect
try { try {
while (true) { while (true) {
val stream = ImportableStream(settings, machine) val stream = ImportableStream(settings, setup)
liveChanges.set(stream) liveChanges.set(stream)
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -343,24 +341,24 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
when (val loaded = machines) { when (val loaded = setups) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> { is LoadState.Loaded -> {
// Only worth choosing when there is a choice. // Only worth choosing when there is a choice.
if (loaded.value.size > 1) { if (loaded.value.size > 1) {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
loaded.value.forEach { machine -> loaded.value.forEach { setup ->
TextButton( TextButton(
onClick = { onClick = {
chosen = machine chosen = setup
loadSessions(machine) loadSessions(setup)
} }
) { ) {
Text( Text(
machine.name, setup.name,
color = color =
if (machine.id == chosen?.id) if (setup.id == chosen?.id)
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -377,7 +375,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} else { } else {
ChipGroup( ChipGroup(
label = "Permissions", label = "Permissions",
options = provider?.permissionModes.orEmpty(), options = PERMISSION_MODES,
selected = permissionMode, selected = permissionMode,
onSelect = { permissionMode = it }, onSelect = { permissionMode = it },
) )
@@ -441,9 +439,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
val machine = chosen ?: return@TextButton val setup = chosen ?: return@TextButton
confirming = null confirming = null
handOver(targets) { ids -> deleteImportable(settings, machine.id, ids) } handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
} }
) { ) {
// Coloured by consequence: this takes something away, wherever it appears. // Coloured by consequence: this takes something away, wherever it appears.
@@ -12,13 +12,13 @@ package com.example.aiapp
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in * the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
* the next listing. * the next listing.
*/ */
class ImportableStream(settings: ServerSettings, private val machine: String) { class ImportableStream(settings: ServerSettings, private val setup: String) {
private val stream = Sse(settings) private val stream = Sse(settings)
fun close() = stream.close() fun close() = stream.close()
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) { fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
stream.run("/machines/$machine/importable/events", onOpen) { _, data -> stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange) if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
} }
} }
@@ -16,7 +16,6 @@ enum class Language {
CPP, CPP,
CSHARP, CSHARP,
DART, DART,
DIFF,
FISH, FISH,
GO, GO,
JAVA, JAVA,
@@ -101,7 +100,7 @@ fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(la
// Lazy for the same reason [RULES] is, since it reads it. // Lazy for the same reason [RULES] is, since it reads it.
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy { private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } + RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
mapOf(Language.DIFF to ::scanDiff, Language.MARKDOWN to ::scanMarkdown) mapOf(Language.MARKDOWN to ::scanMarkdown)
} }
private val C_STYLE = BlockComment("/*", "*/", nests = false) private val C_STYLE = BlockComment("/*", "*/", nests = false)
@@ -1,402 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The models on one machine, the downloads putting more there, and HuggingFace to find them in.
*
* This was a tab of its own, about the backend's own disk. It moved under the machine's llama.cpp
* provider on 2026-09-19, when a download came to run on the machine that will serve the file:
* there is no such thing as "the models", only this machine's, and the screen that decides how a
* model is loaded is the screen that should be able to fetch one.
*
* Everything here is the machine's state rather than this screen's. A download is a process on that
* machine with its progress written beside the partial file, so closing the app, locking the phone
* or restarting the backend does not touch it, and a second device watching sees the same numbers.
*/
@Stable
class MachineModelsState(
private val settings: ServerSettings,
private val machineId: String,
private val scope: CoroutineScope,
) {
var state by mutableStateOf<LoadState<Models>>(LoadState.Loading)
private set
var query by mutableStateOf("")
var results by mutableStateOf<LoadState<List<RemoteRepo>>?>(null)
private set
var openRepo by mutableStateOf<String?>(null)
private set
var repoFiles by mutableStateOf<LoadState<List<RemoteFile>>?>(null)
private set
/** What the last action said went wrong, shown above the list that action was taken in. */
var actionError by mutableStateOf<String?>(null)
private set
val models: Models?
get() = (state as? LoadState.Loaded)?.value
val downloads: List<Download>
get() = models?.downloads.orEmpty()
/** How big each downloaded model is, by key, for the cards the provider screen draws. */
val sizes: Map<String, Long>
get() = models?.local.orEmpty().associate { it.key to it.bytes }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchMachineModels(settings, machineId))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
/** Runs [action], says what it said if it failed, and asks the machine again either way. */
private fun act(action: suspend () -> Unit) {
scope.launch {
actionError =
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
reload()
}
}
fun search() {
openRepo = null
results = LoadState.Loading
scope.launch {
results =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(searchModels(settings, query)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
fun toggleRepo(repo: String) {
if (openRepo == repo) {
openRepo = null
return
}
openRepo = repo
repoFiles = LoadState.Loading
scope.launch {
repoFiles =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchRepoFiles(settings, machineId, repo))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
fun download(repo: String, file: String) = act {
startDownload(settings, machineId, repo, file)
}
fun cancel(key: String) = act { cancelDownload(settings, machineId, key) }
fun remove(key: String) = act { deleteModel(settings, machineId, key) }
}
/**
* One machine's models, asked for again while this screen is open.
*
* Polled rather than pushed: a download belongs to a machine, not to any session, so it has no
* event stream of its own. Faster while something is downloading, because that is the only thing
* here that changes by itself -- each ask is a round trip to that machine, and once a minute would
* be a progress bar that moved in jumps.
*
* [onLocalChange] fires when the set of models on the machine changes, which is how the screen
* around this learns that a download has become a model it must now draw settings for.
*
* [enabled] is false for a provider that holds no files of its own -- the Claude CLI names its
* models rather than storing them -- and then nothing is asked of the machine at all. Taken as a
* parameter rather than decided by the caller's `if`, so that this is composed unconditionally and
* keeps its search results across the moment the provider's kind arrives.
*/
@Composable
fun rememberMachineModels(
settings: ServerSettings,
machineId: String,
enabled: Boolean,
onLocalChange: () -> Unit,
): MachineModelsState {
val scope = rememberCoroutineScope()
val state = remember(settings, machineId) { MachineModelsState(settings, machineId, scope) }
LaunchedEffect(state, enabled) {
if (!enabled) return@LaunchedEffect
var known: List<String>? = null
while (true) {
state.reload()
val local = state.models?.local?.map { it.key }
if (local != null) {
if (known != null && known != local) onLocalChange()
known = local
}
delay(if (state.downloads.any { it.state == "running" }) 1500 else 5000)
}
}
return state
}
/** What is being fetched onto this machine, above the models it already has. */
fun LazyListScope.downloadCards(state: MachineModelsState) {
uniqueItems(state.downloads, key = { "download:" + it.key }) { download ->
DownloadCard(
download = download,
onCancel = { state.cancel(download.key) },
onResume = { state.download(download.repo, download.file) },
onRemove = { state.remove(download.key) },
)
}
}
/**
* Finding a model to fetch: a search, and what it found.
*
* Below the models this machine has rather than above them, because what is here is what the reader
* came for and getting another is the rarer errand.
*/
fun LazyListScope.modelSearch(state: MachineModelsState) {
item("search") {
Spacer(Modifier.height(16.dp))
Text("Get another model", style = MaterialTheme.typography.titleSmall)
Text(
"Downloaded onto this machine, which is where llama.cpp reads it from.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
val keyboard = LocalSoftwareKeyboardController.current
LabelledField(
label = "Search HuggingFace",
value = state.query,
onValueChange = { state.query = it },
// The keyboard's own key searches, and puts itself away to show what it found. The
// button below this is under the keyboard while it is up, so without this the only
// way to press it is to dismiss the keyboard first -- which nothing on screen says.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions =
KeyboardActions(
onSearch = {
keyboard?.hide()
state.search()
}
),
modifier = Modifier.fillMaxWidth(),
)
TextButton(
enabled = state.query.isNotBlank(),
onClick = {
keyboard?.hide()
state.search()
},
) {
Text("Search")
}
}
when (val found = state.results) {
null -> {}
is LoadState.Loading -> item("searching") { CircularProgressIndicator() }
is LoadState.Error ->
item("search-failed") { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
uniqueItems(found.value, key = { "repo:" + it.id }) { repo ->
val open = state.openRepo == repo.id
RepoRow(repo, expanded = open) { state.toggleRepo(repo.id) }
// Inside the expanded repository's own item rather than as a section after the
// list: drawn after every card, a repository's files read as belonging to
// whichever card happened to be last.
if (open) {
when (val files = state.repoFiles) {
null -> {}
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error ->
Text(files.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
Column {
val busy = state.downloads.map { it.key }.toSet()
files.value.forEach { file ->
RepoFileRow(
file,
downloading = "${repo.id}/${file.path}" in busy,
) {
state.download(repo.id, file.path)
}
}
}
}
}
}
}
}
@Composable
private fun DownloadCard(
download: Download,
onCancel: () -> Unit,
onResume: () -> Unit,
onRemove: () -> Unit,
) {
val running = download.state == "running" || download.state == "verifying"
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(download.file, style = MaterialTheme.typography.titleSmall)
Text(
download.repo,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. HuggingFace sends no size when it
// was never told one, and a bar drawn from a guess is worse than one that admits it
// is counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say
// the opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
style = MaterialTheme.typography.bodySmall,
)
} else if (running) {
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
)
}
download.error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
download.state,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
if (running) {
TextButton(onClick = onCancel) { Text("Cancel") }
} else {
// A stopped download kept its partial file, so carrying on is the cheap
// answer and starting again is not the only one offered.
TextButton(onClick = onResume) { Text("Resume") }
TextButton(onClick = onRemove) { Text("Remove") }
}
}
}
}
}
@Composable
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
"${repo.downloads} downloads · ${repo.likes} likes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
}
}
}
@Composable
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(file.path, style = MaterialTheme.typography.bodyMedium)
Text(
gigabytes(file.bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
file.have -> "Downloaded"
downloading -> "Downloading"
else -> "Download"
}
)
}
}
}
fun gigabytes(bytes: Long): String =
if (bytes >= 1_000_000_000) {
"%.2f GB".format(bytes / 1_000_000_000.0)
} else {
"%.0f MB".format(bytes / 1_000_000.0)
}
@@ -1,53 +0,0 @@
package com.example.aiapp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
/**
* The app's root screen, in the full-width panel [SidePanels] slides over a session from the left.
*
* Not a list of its own but [MainScreen] itself, and the whole width of the screen: what a right
* swipe gets is the screen Back would have got, moved over the session instead of replacing it. The
* session stays composed underneath, with its stream open and its draft and scroll position where
* they were, so swiping the panel back off returns to it for nothing -- where Back and a tap costs
* the whole transcript over the tunnel again.
*
* Tapping the session already open is that same swipe back rather than a fresh screen: reopening it
* would hand [SessionScreen] a new summary for the conversation it is already showing.
*
* [onGone] is the one thing the list can do that this panel cannot survive -- deleting the very
* session it is drawn over. There is nothing left to swipe back into, so that closes the screen.
*/
@Composable
fun MainPanel(
settings: ServerSettings,
sessionId: String,
active: Boolean,
onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit,
onProvider: (String, String) -> Unit,
onClose: () -> Unit,
onGone: () -> Unit,
) {
// Asked again each time the panel opens: who is working and who is waiting on an answer is
// exactly what changed while the session underneath was being read.
var reloadToken by remember(sessionId) { mutableIntStateOf(0) }
LaunchedEffect(active) { if (active) reloadToken++ }
MainScreen(
settings = settings,
reloadToken = reloadToken,
onOpen = { if (it.id == sessionId) onClose() else onOpen(it) },
onSpawn = onSpawn,
onImported = onImported,
onSettings = onSettings,
onProvider = onProvider,
onDeleted = { if (it == sessionId) onGone() },
)
}
@@ -26,23 +26,19 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
/** /**
* The app's root: one title, and three views of the backend behind it. * The app's root: one title, and four views of the backend behind it.
* *
* These were screens reached by words in a row under the title, and the row was already full. Tabs * These were four screens reached by four words in a row under the title, and the row was already
* say the same thing in less space and say one more thing besides: that these are places to be * full. Tabs say the same thing in less space and say one more thing besides: that these are places
* rather than errands to run. Sessions, the machine's importable history and the machines * to be rather than errands to run. Sessions, the machine's importable history, the models on it
* themselves are all *the same backend*, looked at three ways, and none is a step down from * and the machines themselves are all *the same backend*, looked at four ways, and none is a step
* another. Settings still is, which is why it stays a pushed screen with its own Back. * down from another. Settings still is, which is why it stays a pushed screen with its own Back.
*
* Models were a fourth tab until 2026-09-19. They are a machine's models now -- downloaded onto the
* machine that has to serve them -- so they live under that machine's llama.cpp provider, beside
* the settings deciding how each one is loaded. A tab about "the models" was a claim that there is
* one such set, and there is one per machine.
*/ */
private enum class MainTab(val label: String) { private enum class MainTab(val label: String) {
Sessions("Sessions"), Sessions("Sessions"),
Import("Import"), Import("Import"),
Machines("Machines"), Models("Models"),
Setups("Setups"),
} }
@Composable @Composable
@@ -55,10 +51,6 @@ fun MainScreen(
onSpawn: () -> Unit, onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit, onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
/** One machine's provider, opened from the machines tab. */
onProvider: (String, String) -> Unit,
/** A session the list has just deleted; see [SessionListScreen]. */
onDeleted: (String) -> Unit = {},
) { ) {
var tab by remember { mutableStateOf(MainTab.Sessions) } var tab by remember { mutableStateOf(MainTab.Sessions) }
var refreshToken by remember { mutableIntStateOf(0) } var refreshToken by remember { mutableIntStateOf(0) }
@@ -148,12 +140,11 @@ fun MainScreen(
reloadToken = token, reloadToken = token,
onOpen = onOpen, onOpen = onOpen,
onSpawn = onSpawn, onSpawn = onSpawn,
onDeleted = onDeleted,
) )
MainTab.Import -> MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported) ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.Machines -> MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
MachinesScreen(settings = settings, reloadToken = token, onProvider = onProvider) MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
} }
} }
} }
@@ -32,7 +32,6 @@ import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.getUnescapedTextInNode
import com.mikepenz.markdown.utils.resolveImageAlt import com.mikepenz.markdown.utils.resolveImageAlt
import com.mikepenz.markdown.utils.resolveImageLink import com.mikepenz.markdown.utils.resolveImageLink
import java.net.URI
import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.ASTNode
@@ -90,7 +89,6 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
content.buildMarkdownAnnotatedString(node, style, settings) content.buildMarkdownAnnotatedString(node, style, settings)
} }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val fileLinkHandler = LocalFileLinkHandler.current
val onPlainTap = LocalMarkdownTap.current val onPlainTap = LocalMarkdownTap.current
val layout = remember { Ref<TextLayoutResult>() } val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour. // The renderer's own rule for a style that names no colour: the theme's text colour.
@@ -129,7 +127,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
when { when {
url != null -> { url != null -> {
up.consume() up.consume()
if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url) uriHandler.openUri(url)
} }
onPlainTap != null -> { onPlainTap != null -> {
up.consume() up.consume()
@@ -166,55 +164,6 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
*/ */
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null } val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* Opens a markdown destination inside the current session when it names a file on that session's
* machine. Null outside a session, where every link keeps its ordinary URI behaviour.
*/
val LocalFileLinkHandler = compositionLocalOf<((String) -> Boolean)?> { null }
/**
* A stable markdown link handler whose behaviour follows the latest [onFile]. Keeping its identity
* stable matters: every visible markdown paragraph reads it, and a session recomposes on every
* streamed event.
*/
@Composable
fun rememberFileLinkHandler(onFile: (String) -> Unit): (String) -> Boolean {
val latest = rememberUpdatedState(onFile)
return remember {
{ destination ->
val path = filePathOf(destination)
if (path == null) false
else {
latest.value(path)
true
}
}
}
}
/**
* The path named by a local-file markdown destination.
*
* Only absolute paths and local `file:` URIs are claimed. A relative destination might be a web
* link, and sending one to a machine's filesystem would silently give an ordinary link a different
* meaning. Editors commonly append a line and optional column; the current viewer opens the file
* itself, so those coordinates are removed here.
*/
internal fun filePathOf(destination: String): String? {
val uri = runCatching { URI(destination) }.getOrNull()
val path =
when {
destination.startsWith("/") && !destination.startsWith("//") ->
uri?.path ?: destination.substringBefore('#').substringBefore('?')
uri != null &&
uri.scheme.equals("file", ignoreCase = true) &&
(uri.host.isNullOrEmpty() || uri.host == "localhost") -> uri.path
else -> null
}
if (path.isNullOrEmpty() || !path.startsWith('/')) return null
return path.replace(Regex(":\\d+(?::\\d+)?$"), "")
}
/** /**
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the * [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text * behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
@@ -21,25 +21,12 @@ const val DEFAULT_MODEL = "default"
* one model rather than one model from another. Anything that does not look like that is returned * one model rather than one model from another. Anything that does not look like that is returned
* untouched. * untouched.
* *
* A llama.cpp session's model is not an identifier at all -- it is `owner/repo/file.gguf`, where
* the file was downloaded from -- so what is kept is the file, which is the part that tells two
* models apart, and the extension goes with the directories. The model's *own* name is better still
* and is not derivable here: it is inside the file, and only the server has ever opened it. Where a
* screen has the server's answer it should prefer it; this is the floor under every screen that
* does not.
*
* A display decision, not a correction: the full name is what the session reports. * A display decision, not a correction: the full name is what the session reports.
*/ */
fun modelLabel(model: String?): String { fun modelLabel(model: String?): String {
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
if (name.endsWith(GGUF)) {
return name.substringAfterLast('/').removeSuffix(GGUF)
}
return name.removePrefix("claude-").replace(DATED_SUFFIX, "") return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
} }
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */ /** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
private val DATED_SUFFIX = Regex("""-\d{8}$""") private val DATED_SUFFIX = Regex("""-\d{8}$""")
/** What every model a llama.cpp session can run is stored as. */
private const val GGUF = ".gguf"
@@ -0,0 +1,374 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Models on the backend, and HuggingFace to get more from.
*
* Everything here is the server's state rather than this screen's: what is downloaded, and what is
* downloading, are the same answers on every enrolled device, and a download started here keeps
* going when this screen closes.
*/
@Composable
fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) }
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<LoadState<List<RemoteRepo>>?>(null) }
var openRepo by remember { mutableStateOf<String?>(null) }
var repoFiles by remember { mutableStateOf<LoadState<List<RemoteFile>>?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchModels(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// Polled rather than pushed: a download belongs to the machine, not to any session, so it has
// no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the
// loop with a read now rather than leaving the reader watching for a second and a half.
LaunchedEffect(reloadToken) {
while (true) {
reload()
delay(1500)
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search HuggingFace") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
TextButton(
enabled = query.isNotBlank(),
onClick = {
openRepo = null
results = LoadState.Loading
scope.launch {
results =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(searchModels(settings, query))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
},
) {
Text("Search")
}
Spacer(Modifier.height(8.dp))
LazyColumn(Modifier.fillMaxSize()) {
when (val current = state) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(current.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded -> {
if (current.value.downloads.isNotEmpty()) {
item { SectionLabel("Downloading") }
uniqueItems(current.value.downloads, key = { it.key + it.run }) { download
->
DownloadCard(download) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
cancelDownload(settings, download.key)
}
}
.exceptionOrNull()
?.message
}
}
}
}
item { SectionLabel("On the backend") }
if (current.value.local.isEmpty()) {
item {
Text(
"None yet. Search above to find one.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
uniqueItems(current.value.local, key = { it.key }) { model ->
LocalModelCard(model) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
deleteModel(settings, model.key)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
results?.let { found ->
item { SectionLabel("HuggingFace") }
when (found) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
uniqueItems(found.value, key = { it.id }) { repo ->
val open = openRepo == repo.id
RepoRow(repo, expanded = open) {
if (open) {
openRepo = null
} else {
openRepo = repo.id
repoFiles = LoadState.Loading
scope.launch {
repoFiles =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(
fetchRepoFiles(settings, repo.id)
)
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
}
// Inside the expanded repository's own item rather than as a section
// after the list: drawn after every card, a repository's files read as
// belonging to whichever card happened to be last.
if (open) {
when (val files = repoFiles) {
null -> {}
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error ->
Text(files.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
Column {
val busy =
(state as? LoadState.Loaded)
?.value
?.downloads
.orEmpty()
.filter { it.state == "running" }
.map { it.key }
.toSet()
files.value.forEach { file ->
RepoFileRow(
file,
downloading = "${repo.id}/${file.path}" in busy,
) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
startDownload(
settings,
repo.id,
file.path,
)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
}
}
}
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Spacer(Modifier.height(12.dp))
Text(text, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun DownloadCard(download: Download, onCancel: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(download.file, style = MaterialTheme.typography.titleSmall)
Text(
download.repo,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. The server sends no total when it was
// never told one, and a bar drawn from a guess is worse than one that admits it is
// counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say the
// opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
style = MaterialTheme.typography.bodySmall,
)
} else {
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
)
}
download.error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row {
Text(
download.state,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
if (download.state == "running") {
TextButton(onClick = onCancel) { Text("Cancel") }
}
}
}
}
}
@Composable
private fun LocalModelCard(model: LocalModel, onDelete: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(model.file, style = MaterialTheme.typography.titleSmall)
Text(
"${model.repo} · ${gigabytes(model.bytes)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onDelete) { Text("Delete") }
}
}
}
@Composable
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
"${repo.downloads} downloads · ${repo.likes} likes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
}
}
}
@Composable
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(file.path, style = MaterialTheme.typography.bodyMedium)
Text(
gigabytes(file.bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
file.have -> "Downloaded"
downloading -> "Downloading"
else -> "Download"
}
)
}
}
}
private fun gigabytes(bytes: Long): String =
if (bytes >= 1_000_000_000) {
"%.2f GB".format(bytes / 1_000_000_000.0)
} else {
"%.0f MB".format(bytes / 1_000_000.0)
}
@@ -28,7 +28,7 @@ import androidx.compose.ui.unit.sp
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the * This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
* grounds that a system font may not have the glyph. That objection is about *relying* on a system * grounds that a system font may not have the glyph. That objection is about *relying* on a system
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here * font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
* is `app/build-icon-font.sh`'s output -- eighteen glyphs, 2.9 KB, subset out of the 3 MB symbols * is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that * font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
* the script did not subset is a glyph that silently isn't there. * the script did not subset is a glyph that silently isn't there.
* *
@@ -136,34 +136,6 @@ val EDIT_GLYPH = glyph(0xF03EB)
*/ */
val SAVE_GLYPH = glyph(0xF0193) val SAVE_GLYPH = glyph(0xF0193)
/**
* `md-menu` -- the burger: three stacked rules, drawn as the handle a row is dragged by.
*
* The mark for "take hold of this and move it" rather than for a menu, which is what it means on a
* row that has one: three rules look like the rows of a list, and the only thing here that draws
* them is a list being rearranged. Nothing else in this app opens a menu from a burger, so the two
* senses cannot be confused.
*/
val DRAG_GLYPH = glyph(0xF035C)
/**
* `md-console_line` -- a shell prompt: a backgrounded command, in the panel beside the turn.
*
* The four marks here are one set, drawn by `backgroundTaskLook`: they exist because the kind of a
* background task used to be a word on a line of its own, which on a list of commands was the same
* two words down the whole panel. Each keeps its words as the description a screen reader is given.
*/
val COMMAND_GLYPH = glyph(0xF07B7)
/** `md-robot` -- a subagent: something running that is doing its own reasoning. */
val AGENT_GLYPH = glyph(0xF06A9)
/** `md-sitemap` -- a workflow: steps arranged by something other than the agent itself. */
val WORKFLOW_GLYPH = glyph(0xF04AA)
/** `md-help_circle_outline` -- a background task of a kind this build has not heard of. */
val UNKNOWN_GLYPH = glyph(0xF0625)
/** /**
* The size an icon draws at beside a line of text. * The size an icon draws at beside a line of text.
* *
@@ -193,7 +165,7 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
* own corners and beside a title it arrived at the first letter. And it is taller than any header's * own corners and beside a title it arrived at the first letter. And it is taller than any header's
* text, which is what lets the button fill a header row rather than sit in the middle of one. * text, which is what lets the button fill a header row rather than sit in the middle of one.
*/ */
val GLYPH_BUTTON_SIZE = 48.dp private val GLYPH_BUTTON_SIZE = 48.dp
/** /**
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside * The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
@@ -34,8 +34,6 @@ import org.json.JSONObject
* gets a push from Google's servers, which would mean this backend talking to Google about * gets a push from Google's servers, which would mean this backend talking to Google about
* somebody's coding sessions, and the whole point of the tunnel is that it does not. * somebody's coding sessions, and the whole point of the tunnel is that it does not.
* *
* Every moment it hears about goes to the drawer; [show] decides what else is done with it.
*
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as * The cost Android charges is a notification of its own that cannot be dismissed. That is made as
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows * quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it * no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
@@ -140,11 +138,10 @@ class NotificationService : Service() {
// Nothing to tell somebody about the session they are reading. The transcript in front of // Nothing to tell somebody about the session they are reading. The transcript in front of
// them is already saying it. // them is already saying it.
if (isOnScreen(notification.sessionId)) return if (isOnScreen(notification.sessionId)) return
// The app is up, so it says this itself as a banner over whatever screen they are on -- // The app is up: it says this itself, as a banner over whatever screen they are on. Never
// which interrupts, where the drawer's row records: a banner lasts seconds and reaches only // both -- one thing happened, and a drawer filling up behind an app that already showed you
// somebody already looking. Both go up, and the banner having done the interrupting is what // each one is a drawer nobody reads.
// makes the row a silent one. if (handOver(notification)) return
val banner = handOver(notification)
val manager = NotificationManagerCompat.from(this) val manager = NotificationManagerCompat.from(this)
// Two different noes, and both are answers rather than faults: the runtime permission // Two different noes, and both are answers rather than faults: the runtime permission
// refused, and notifications switched off for the app in Android's own settings. // refused, and notifications switched off for the app in Android's own settings.
@@ -175,7 +172,6 @@ class NotificationService : Service() {
.setAutoCancel(true) .setAutoCancel(true)
.setWhen((notification.at * 1000).toLong()) .setWhen((notification.at * 1000).toLong())
.setShowWhen(true) .setShowWhen(true)
.setSilent(banner)
.build() .build()
manager.notify(notification.sessionId, ALERT_ID, built) manager.notify(notification.sessionId, ALERT_ID, built)
} }
@@ -266,8 +262,7 @@ class NotificationService : Service() {
* Whether there is an app to reach is the subscriber count rather than a flag of its own: * Whether there is an app to reach is the subscriber count rather than a flag of its own:
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends * [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
* nor blocks the thread reading the stream, and the buffer is there so a handful of * nor blocks the thread reading the stream, and the buffer is there so a handful of
* sessions finishing together all land rather than the last one winning. Reaching the app * sessions finishing together all land rather than the last one winning.
* does not stop the drawer's row; it makes it a silent one.
*/ */
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8) private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
@@ -277,11 +272,7 @@ class NotificationService : Service() {
private fun handOver(notification: SessionNotification) = private fun handOver(notification: SessionNotification) =
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification) toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
/** /** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
* Somebody is looking at [sessionId]; nothing is posted about it until they stop, and
* whatever the drawer is already holding about it goes now rather than waiting to be swiped
* away. Opening the session *is* reading the notification, whichever way they got here.
*/
fun showing(context: Context, sessionId: String) { fun showing(context: Context, sessionId: String) {
onScreen = sessionId onScreen = sessionId
// Whatever was posted about it before is about to be read, so it has nothing left to // Whatever was posted about it before is about to be read, so it has nothing left to
@@ -1,121 +0,0 @@
package com.example.aiapp
import android.content.Context
import androidx.core.content.edit
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
private const val PENDING_MESSAGES = "pending-messages"
/** A quiet user bubble below the durable transcript. */
internal data class QueuedMessage(
val id: String,
val text: String,
val attachments: List<String>,
val refusal: String? = null,
/** This phone is still waiting for any durable event that says the server accepted it. */
val local: Boolean = false,
/** The HTTP request returned successfully; the provider event is still outstanding. */
val serverAccepted: Boolean = false,
)
internal fun localPendingMessage(text: String, attachments: List<String>) =
QueuedMessage("local-${UUID.randomUUID()}", text, attachments, local = true)
private fun QueuedMessage.matches(text: String, attachments: List<String>) =
this.text == text && this.attachments == attachments
/** Replaces the local bridge with the server's durable waiting message, without drawing both. */
internal fun reconcileQueuedMessage(
queued: List<QueuedMessage>,
event: SessionEvent.MessageQueued,
): List<QueuedMessage> {
if (queued.any { !it.local && it.id == event.id }) return queued
val at = queued.indexOfFirst { it.local && it.matches(event.text, event.attachments) }
if (at < 0) return queued + QueuedMessage(event.id, event.text, event.attachments)
return queued.mapIndexed { index, message ->
if (index == at) QueuedMessage(event.id, event.text, event.attachments) else message
}
}
/** Removes exactly the pending bubble that became a provider-received user message. */
internal fun reconcileUserMessage(
queued: List<QueuedMessage>,
event: SessionEvent.UserMessage,
): List<QueuedMessage> {
val at =
event.id?.let { id -> queued.indexOfFirst { !it.local && it.id == id }.takeIf { it >= 0 } }
?: queued.indexOfFirst { it.local && it.matches(event.text, event.attachments) }
return if (at < 0) queued else queued.filterIndexed { index, _ -> index != at }
}
/** Keeps a failed send in place and puts its actionable failure in that message's bubble. */
internal fun markPendingFailure(
queued: List<QueuedMessage>,
id: String,
failure: String,
): List<QueuedMessage> = queued.map { message ->
if (message.local && message.id == id) message.copy(refusal = failure) else message
}
/** Stops persisting a send once the server owns it, while its bubble awaits the provider event. */
internal fun markPendingAccepted(queued: List<QueuedMessage>, id: String): List<QueuedMessage> =
queued.map { message ->
if (message.local && message.id == id) message.copy(serverAccepted = true) else message
}
internal fun discardPendingMessage(
queued: List<QueuedMessage>,
id: String,
): List<QueuedMessage> = queued.filterNot { it.local && it.id == id }
/** Restores sends for which this phone has not yet seen a durable server event. */
internal fun loadPendingMessages(context: Context, key: String): List<QueuedMessage> {
val encoded =
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).getString(key, null)
?: return emptyList()
return try {
val messages = JSONArray(encoded)
List(messages.length()) { index ->
val message = messages.getJSONObject(index)
val attachments = message.optJSONArray("attachments") ?: JSONArray()
QueuedMessage(
id = message.getString("id"),
text = message.getString("text"),
attachments = List(attachments.length()) { attachments.getString(it) },
refusal = message.optString("refusal").takeIf { it.isNotEmpty() },
local = true,
)
}
} catch (_: org.json.JSONException) {
// A corrupt local outbox is not useful on the next open either. Remove it rather than
// repeatedly pretending it decoded to an intentionally empty one.
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).edit { remove(key) }
emptyList()
}
}
/** Stores only sends the server has not confirmed; everything accepted is the server's to keep. */
internal fun savePendingMessages(context: Context, key: String, queued: List<QueuedMessage>) {
val local = queued.filter { it.local && !it.serverAccepted }
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).edit {
if (local.isEmpty()) {
remove(key)
} else {
putString(
key,
JSONArray(
local.map { message ->
JSONObject()
.put("id", message.id)
.put("text", message.text)
.put("attachments", JSONArray(message.attachments))
.put("refusal", message.refusal ?: "")
}
)
.toString(),
)
}
}
}
@@ -1,211 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Relays a provider CLI's headless browser login without ever owning its credentials.
*
* The URL and code live only in this composition. The CLI process on [machineId] remains the one
* OAuth client and the only writer of its credential file.
*/
@Composable
fun ProviderLoginDialog(
settings: ServerSettings,
machineId: String,
machineName: String,
provider: String,
onDismiss: () -> Unit,
onSignedIn: () -> Unit,
) {
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
var login by remember(machineId, provider) { mutableStateOf<ProviderLogin?>(null) }
var code by remember(machineId, provider) { mutableStateOf("") }
var error by remember(machineId, provider) { mutableStateOf<String?>(null) }
var retry by remember(machineId, provider) { mutableIntStateOf(0) }
suspend fun follow(initial: ProviderLogin): ProviderLogin {
var current = initial
val wasSubmitting = initial.state == "submitting"
while (current.state == "starting" || current.state == "submitting") {
delay(400)
current =
withContext(Dispatchers.IO) {
fetchProviderLogin(
settings,
machineId,
provider,
current.attempt,
)
}
login = current
}
if (wasSubmitting && current.state == "waitingForCode" && current.detail == null) {
current =
current.copy(
detail = "That code was not accepted. Copy the complete code and try again."
)
login = current
}
return current
}
LaunchedEffect(machineId, provider, retry) {
error = null
code = ""
login = null
try {
val started =
withContext(Dispatchers.IO) { startProviderLogin(settings, machineId, provider) }
login = started
if (follow(started).state == "succeeded") {
onSignedIn()
}
} catch (e: ApiException) {
error = e.message
}
}
fun dismiss() {
login
?.takeUnless { it.state in setOf("succeeded", "failed", "cancelled") }
?.let {
scope.launch(Dispatchers.IO) {
runCatching { cancelProviderLogin(settings, machineId, provider, it.attempt) }
}
}
onDismiss()
}
AlertDialog(
onDismissRequest = ::dismiss,
title = { Text("Sign in to Claude") },
text = {
Column {
Text(
"Claude will sign in on $machineName. Open the authorization page, then " +
"paste the code it gives you here."
)
Spacer(Modifier.height(12.dp))
when (val current = login) {
null ->
if (error == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Text("Starting sign-in…")
}
}
else ->
when (current.state) {
"starting",
"submitting" ->
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Text(
if (current.state == "submitting") "Checking code…"
else "Starting sign-in…"
)
}
"waitingForCode" -> {
TextButton(
onClick = {
runCatching {
current.authorizationUrl?.let(uriHandler::openUri)
}
.onFailure {
error = "Couldn't open the authorization page."
}
},
enabled = current.authorizationUrl != null,
) {
Text("Open authorization page")
}
LabelledField(
label = "Authorization code",
value = code,
onValueChange = { code = it },
)
current.detail?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
}
"succeeded" -> Text("Signed in on $machineName.")
"cancelled" -> Text("Sign-in was cancelled.")
else ->
Text(
current.detail ?: "Sign-in failed.",
color = MaterialTheme.colorScheme.error,
)
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
val current = login
when {
current?.state == "waitingForCode" ->
TextButton(
onClick = {
scope.launch {
error = null
try {
val submitted =
withContext(Dispatchers.IO) {
submitProviderLoginCode(
settings,
machineId,
provider,
current.attempt,
code,
)
}
login = submitted
if (follow(submitted).state == "succeeded") {
onSignedIn()
}
} catch (e: ApiException) {
error = e.message
}
}
},
enabled = code.isNotBlank(),
) {
Text("Continue")
}
error != null || current?.state == "failed" || current?.state == "cancelled" ->
TextButton(onClick = { retry++ }) { Text("Try again") }
current?.state == "succeeded" -> TextButton(onClick = onDismiss) { Text("Done") }
}
},
dismissButton = {
if (login?.state != "succeeded") {
TextButton(onClick = ::dismiss) { Text("Cancel") }
}
},
)
}
@@ -1,117 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* The controls for whatever settings a provider says it takes.
*
* One composable for both screens that offer them the spawn form and the session settings dialog
* and for every provider, because the server declares the list (see `DriverKind::params`) rather
* than this file knowing it. A driver that grows a setting gets a control here with no change to
* the app, which is the whole point: the values that suit one machine ship as defaults, and every
* one of them stays reachable from a phone.
*
* [values] is the whole map and [onChange] hands back the whole map. A key absent from it means the
* setting is unset, which is what every [ParamSpec.unset] describes so clearing a field and never
* touching it are deliberately the same state.
*/
@Composable
fun ProviderParamFields(
specs: List<ParamSpec>,
values: Map<String, String>,
onChange: (Map<String, String>) -> Unit,
/**
* Whether to say which settings wait for a restart. False on a spawn form, where nothing is
* running yet and every setting is about to be read saying it there would be a warning about
* a state the reader cannot be in.
*/
warnAboutRestart: Boolean,
modifier: Modifier = Modifier,
) {
if (specs.isEmpty()) return
Column(modifier.fillMaxWidth()) {
specs.forEach { spec ->
val set = { value: String ->
onChange(
// Blank clears rather than storing an empty string: the server reads an absent
// key as "use the default", and an empty one would be a value it then failed
// to parse.
if (value.isBlank()) values - spec.key else values + (spec.key to value)
)
}
when (spec.kind) {
"choice" -> {
// The first option is what unset means, so selecting it clears the key — see
// `ParamKind::Choice`. Without that the picker could show a default it could
// not return to.
val default = spec.options.firstOrNull().orEmpty()
ChipGroup(
label = spec.label + restartSuffix(spec, warnAboutRestart),
options = spec.options,
selected = values[spec.key] ?: default,
onSelect = { chosen -> set(if (chosen == default) "" else chosen) },
)
}
else ->
LabelledField(
label = spec.label + restartSuffix(spec, warnAboutRestart),
value = values[spec.key].orEmpty(),
onValueChange = set,
hint = spec.unset,
// Prose is written rather than filled in, so it gets the room to be read
// back -- see `ParamKind::Prose`.
lines = if (spec.kind == "prose") 4 else 1,
keyboardOptions = KeyboardOptions(keyboardType = keyboardFor(spec.kind)),
)
}
Spacer(Modifier.height(12.dp))
}
if (warnAboutRestart && specs.any { it.restart }) {
Text(
"A setting marked “on restart” is saved now and read when this session's process " +
"next starts.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* Marks a control whose value will not take effect yet.
*
* On the label rather than beside it, because the reader decides whether to change the thing before
* they touch it a note underneath is read after the decision.
*/
private fun restartSuffix(spec: ParamSpec, warn: Boolean): String =
if (warn && spec.restart) " (on restart)" else ""
/**
* The keyboard for a value's shape. A number field that opens the letter keyboard is one every
* entry is made harder by, and these are nearly all numbers.
*/
private fun keyboardFor(kind: String): KeyboardType =
when (kind) {
"integer" -> KeyboardType.Number
"decimal" -> KeyboardType.Decimal
else -> KeyboardType.Text
}
/**
* How long typing has to stop before edited settings are sent.
*
* Long enough that a number is one request rather than one per digit, short enough that closing the
* dialog straight after typing still saves the save runs on the screen behind it, which outlives
* the dialog, so this delay is not a window the value can be lost in.
*/
const val PARAM_SAVE_DELAY_MS = 700L
@@ -1,531 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* One provider on one machine: what it is, what its shared server is holding, and how each of its
* models is loaded.
*
* This is where a setting that belongs to a *machine* lives, as opposed to one that belongs to a
* session. The two were one list until llama.cpp sessions came to share one server per machine: how
* a model is loaded stopped being anything a single session could decide, because one copy of it in
* memory is what several sessions are talking to.
*
* It is also the only place a loaded model is taken out of memory. Nothing does that on its own
* closing a session leaves the model loaded on purpose, since the next one to want it would
* otherwise pay the load again so the memory is freed here, where what it costs everybody is
* visible.
*/
@Composable
fun ProviderScreen(
settings: ServerSettings,
machineId: String,
provider: String,
/**
* The way back, or null where this is drawn inside something that has one of its own -- the
* session settings screen's second tab. Two ways out stacked above each other is a reader
* asking which of them goes where.
*/
onBack: (() -> Unit)?,
) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<ProviderView>>(LoadState.Loading) }
var reload by remember { mutableIntStateOf(0) }
var editing by remember { mutableStateOf<ProviderModel?>(null) }
var confirmingStop by remember { mutableStateOf(false) }
// What is being done to the server or to one of its models, in a word, and what went wrong
// when it did. Both here rather than per row: these act on the whole machine.
var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
var confirmingDelete by remember { mutableStateOf<ProviderModel?>(null) }
// The machine's own models and what is being fetched onto it. Only for a provider that serves
// files off that machine's disk -- everything else names its models rather than holding them,
// and a search for a GGUF under the Claude CLI would be an offer that leads nowhere.
val kind = (state as? LoadState.Loaded)?.value?.kind
val machineModels =
rememberMachineModels(
settings = settings,
machineId = machineId,
enabled = kind == "llama_cpp",
// A download that became a model is a model this screen has no settings for yet, so
// the view it is drawing is now one model short of the truth.
onLocalChange = { reload++ },
)
LaunchedEffect(reload) {
state =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchProvider(settings, machineId, provider))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// Say what is happening, do it, say what went wrong, refetch: every action on this screen
// changes what it is showing.
val act = { what: String, action: suspend () -> Unit ->
scope.launch {
busy = what
actionError =
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
busy = null
reload++
}
Unit
}
// The models search at the bottom takes the keyboard, and everything below the field it is
// typed in -- the Search button, the results -- is behind it without this.
Column(Modifier.fillMaxSize().imePadding().padding(16.dp)) {
onBack?.let {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(onClick = it) { Text("Back") }
}
}
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> {
val view = current.value
Text(view.name, style = MaterialTheme.typography.titleMedium)
Text(
"on ${view.machine}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
view.command?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(12.dp))
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
busy?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
Text(it, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
}
LazyColumn(Modifier.fillMaxSize()) {
view.server?.let { server ->
item("server") {
ServerCard(
server = server,
maxLoaded = view.maxLoaded,
enabled = busy == null,
onStop = { confirmingStop = true },
onMaxLoaded = { chosen ->
act("Saving…") {
setProviderSettings(
settings,
machineId,
provider,
chosen,
)
}
},
)
Spacer(Modifier.height(12.dp))
}
}
if (view.models.isNotEmpty() && view.modelParams.isNotEmpty()) {
item("models-heading") {
Text("Models", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
}
}
machineModels.actionError?.let { failure ->
item("models-error") {
Text(failure, color = MaterialTheme.colorScheme.error)
}
}
// Above the models: this is what is about to be one of them.
downloadCards(machineModels)
val sizes = machineModels.sizes
uniqueItems(view.models, key = { it.id }) { model ->
ModelCard(
model = model,
specs = view.modelParams,
bytes = sizes[model.id],
onDelete =
if (model.id in sizes) ({ confirmingDelete = model }) else null,
// Tapping opens the settings; a provider whose models take none has
// nothing to open, so the row is not a control.
onEdit =
if (view.modelParams.isEmpty()) null else ({ editing = model }),
onUnload =
if (model.status == "loaded" || model.status == "sleeping") {
{
act("Unloading ${model.label}") {
unloadProviderModel(
settings,
machineId,
provider,
model.id,
)
}
}
} else null,
enabled = busy == null,
)
}
if (kind == "llama_cpp") modelSearch(machineModels)
if (view.mcpServers.isNotEmpty()) {
item("mcp") {
Spacer(Modifier.height(12.dp))
Text("Tool servers", style = MaterialTheme.typography.titleSmall)
Text(
view.mcpServers.joinToString(", ") +
" — configured on the backend, in its config file.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
editing?.let { model ->
val view = (state as? LoadState.Loaded)?.value
ModelSettingsDialog(
model = model,
specs = view?.modelParams.orEmpty(),
onDismiss = { editing = null },
onSave = { params ->
editing = null
act("Saving ${model.label}") {
setModelSettings(settings, machineId, provider, model.id, params)
}
},
)
}
confirmingDelete?.let { model ->
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete ${model.label}?") },
text = {
Text(
"The file is removed from ${(state as? LoadState.Loaded)?.value?.machine ?: "this machine"}. " +
"Nothing here can get it back -- downloading it again is the whole file again. " +
"Sessions using it keep their conversations and cannot start it."
)
},
confirmButton = {
TextButton(
onClick = {
confirmingDelete = null
machineModels.remove(model.id)
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
},
)
}
if (confirmingStop) {
AlertDialog(
onDismissRequest = { confirmingStop = false },
title = { Text("Stop this server?") },
text = {
// Said plainly rather than hidden: this is the only thing that frees the memory,
// and what it costs is that every session on this machine reloads its model.
Text(
"Every model it is holding is unloaded. Sessions using it will show as " +
"exited, and the next message to one loads its model again — which is " +
"the slow part, not the sending."
)
},
confirmButton = {
TextButton(
onClick = {
confirmingStop = false
act("Stopping…") { stopProviderServer(settings, machineId, provider) }
}
) {
Text("Stop")
}
},
dismissButton = { TextButton(onClick = { confirmingStop = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun ServerCard(
server: ServerState,
maxLoaded: Int?,
enabled: Boolean,
onStop: () -> Unit,
onMaxLoaded: (Int?) -> Unit,
) {
// The saved value is what this starts at and what Save is compared against, so a field left
// half-typed is visibly not saved rather than quietly either way.
val saved = maxLoaded?.toString().orEmpty()
var typed by remember(saved) { mutableStateOf(saved) }
var confirming by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text("Model server", style = MaterialTheme.typography.titleSmall)
Text(
if (server.running) {
"Running" + (server.port?.let { ", reached on port $it" } ?: "")
} else {
// Not a fault: nothing is loaded because nothing has asked. Saying it in
// words rather than colouring the row, since "stopped" and "we could not
// ask" would otherwise look the same.
"Not running. A session starts it when it needs a model."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
LabelledField(
label = "Models loaded at once",
value = typed,
onValueChange = { typed = it.filter(Char::isDigit) },
hint = "one -- a second model replaces the first",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
Row(verticalAlignment = Alignment.CenterVertically) {
// Shown whether or not it is running, and disabled when there is nothing to stop:
// a button that comes and goes makes its own absence the message.
TextButton(enabled = enabled && server.running, onClick = onStop) { Text("Stop") }
Spacer(Modifier.weight(1f))
TextButton(
enabled = enabled && typed != saved,
// Saving this while the server is up changes nothing until it comes down
// again, which is asked rather than written underneath -- see [RestartDialog].
onClick = {
if (server.running) confirming = true else onMaxLoaded(typed.toIntOrNull())
},
) {
Text("Save")
}
}
}
}
if (confirming) {
RestartDialog(
title = "Save for the next start?",
text =
"This server is running, and how many models it keeps loaded was decided when it " +
"started. Saving now changes what it does the next time it starts -- stop it " +
"here to have that be now.",
onConfirm = {
confirming = false
onMaxLoaded(typed.toIntOrNull())
},
onDismiss = { confirming = false },
)
}
}
/**
* What a setting read at load time costs, asked before it is written.
*
* One shape for both of the questions on this screen a model's settings and the server's
* because they are the same question: this is read when something starts again, and here is what
* starting again costs. A paragraph under the control said the same thing and was read after the
* decision, if at all.
*/
@Composable
private fun RestartDialog(
title: String,
text: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(text) },
confirmButton = { TextButton(onClick = onConfirm) { Text("Save") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun ModelCard(
model: ProviderModel,
specs: List<ParamSpec>,
/** How big the file is on the machine, for a provider whose models are files. */
bytes: Long?,
onEdit: (() -> Unit)?,
onUnload: (() -> Unit)?,
onDelete: (() -> Unit)?,
enabled: Boolean,
) {
Card(
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.then(if (onEdit != null && enabled) Modifier.clickable(onClick = onEdit) else Modifier)
) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
model.label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
bytes?.let {
Text(
gigabytes(it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// What the server is doing with it, in its own word. Absent means nobody could ask --
// the server is not running -- and the line is left out rather than guessed at.
model.status?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (model.settings.isNotEmpty()) {
Text(
// In the words the dialog uses, and in the order it draws them: a summary
// naming `contextSize` is a summary of a different screen than the one it
// sits under.
specs
.mapNotNull { spec ->
model.settings[spec.key]?.let { "${spec.label} $it" }
}
.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (onUnload != null || onDelete != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Both shown whenever this kind of model has them, disabled rather than
// absent: unloading frees memory and deleting frees disk, and a button that
// comes and goes makes its own absence the message.
onUnload?.let { TextButton(enabled = enabled, onClick = it) { Text("Unload") } }
Spacer(Modifier.weight(1f))
onDelete?.let { TextButton(enabled = enabled, onClick = it) { Text("Delete") } }
}
}
}
}
}
/**
* How one model is loaded.
*
* Saved on Save rather than as it is typed, unlike the session settings dialog: writing this
* unloads the model for everybody using it, which is not something to do once per keystroke.
*/
@Composable
private fun ModelSettingsDialog(
model: ProviderModel,
specs: List<ParamSpec>,
onDismiss: () -> Unit,
onSave: (Map<String, String>) -> Unit,
) {
var params by remember(model.id) { mutableStateOf(model.settings) }
// Asked over this dialog rather than instead of it, so Cancel comes back to the edits rather
// than throwing them away.
var confirming by remember(model.id) { mutableStateOf(false) }
val loaded = model.status == "loaded" || model.status == "sleeping"
AlertDialog(
onDismissRequest = onDismiss,
// Every control here is a number, so the keyboard is up for most of this dialog's life --
// and a dialog that keeps its own size under the keyboard puts Save off the bottom of the
// screen, where nothing on screen says it is there. Taking the insets ourselves is what
// lets `imePadding` shrink it instead.
properties = DialogProperties(decorFitsSystemWindows = false),
modifier = Modifier.imePadding(),
title = { Text(model.label) },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
ProviderParamFields(
specs = specs,
values = params,
onChange = { params = it },
// Every one of these is read at load time, which is what Save stops to say --
// marking each control "on restart" would repeat it six times over.
warnAboutRestart = false,
)
}
},
confirmButton = {
TextButton(onClick = { if (loaded) confirming = true else onSave(params) }) {
Text("Save")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
if (confirming) {
RestartDialog(
title = "Unload ${model.label}?",
text =
"It is in memory now, and these are read when it is loaded. Saving takes it out " +
"of memory; the sessions using it load it again with these settings on their " +
"next message.",
onConfirm = {
confirming = false
onSave(params)
},
onDismiss = { confirming = false },
)
}
}
@@ -1,12 +1,10 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -20,14 +18,6 @@ import androidx.compose.ui.unit.dp
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three * monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
* copies of "clip, fill, pad" drift apart the first time one is adjusted. * copies of "clip, fill, pad" drift apart the first time one is adjusted.
* *
* **Nothing in here wraps; it scrolls sideways instead.** This is column-aligned far more often
* than it is prose -- a diff, a table, a test run, a command and its arguments -- and wrapping
* destroys exactly the alignment that was carrying the meaning, while turning one line into four
* and a run of them into a wall. The scroll belongs to the block rather than to each line so that
* the lines stay aligned with each other as it moves: one offset for the whole column is what makes
* a shifted diff still read as a diff. Every [Text] inside is therefore drawn with `softWrap =
* false`, which is the half of this a caller has to remember.
*
* The colour is [rawSurface], which is also what a code block inside a reply is given. * The colour is [rawSurface], which is also what a code block inside a reply is given.
*/ */
@Composable @Composable
@@ -39,10 +29,6 @@ fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.()
// rectangle drawn at the same radius as the one behind it reads as a misprint. // rectangle drawn at the same radius as the one behind it reads as a misprint.
.clip(MaterialTheme.shapes.extraSmall) .clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface) .background(rawSurface)
// Clipped and filled before this, so the tint is the viewport and does not scroll away
// from under the text; padded after it, so the inset travels with the content and the
// last column does not end flush against the edge.
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
content = content, content = content,
) )
@@ -1,284 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Dragging a row of a [androidx.compose.foundation.lazy.LazyColumn] into a different place in it.
*
* Generic rather than the session list's own, because "hold this and move it" is one gesture
* wherever it appears and the arithmetic below is the whole of it. The list itself is left alone:
* this reports a move and the caller decides what a move means -- it is the caller that holds the
* rows and the caller that tells a server about the new order.
*
* The drag is on a [ReorderHandle] rather than on the row, which is what keeps it out of the way of
* the scroll. A whole row that can be dragged sideways-ish is a row that sometimes eats a fling,
* and a list is scrolled far more often than it is rearranged.
*/
class Reorder
internal constructor(
private val listState: LazyListState,
private val scope: CoroutineScope,
private val haptics: HapticFeedback,
/** What the [EDGE] band is in pixels here; a band in raw pixels is one screen's answer. */
private val density: Density,
/**
* The caller's own lists are what move; these are [State] so that the gesture, which outlives a
* recomposition, is never holding the first composition's copy of them.
*/
private val onMove: State<(from: Int, to: Int) -> Unit>,
private val onSettled: State<() -> Unit>,
) {
/** The key of the row in hand, or null when nothing is being dragged. */
var held by mutableStateOf<Any?>(null)
private set
/** Where the list had laid the row out when it was taken hold of, in viewport pixels. */
private var grabbedAt = 0
/** How far the finger has moved since, which is what the row is drawn following. */
private var dragged by mutableFloatStateOf(0f)
/** How far the list has scrolled under it since -- see [follow]. */
private var scrolled = 0f
/** The index the row has been moved to so far, which is what the next move counts from. */
private var at = 0
/** Where it started, so that a handle merely pressed is not reported as a rearrangement. */
private var from = 0
/**
* How much of the travel below the moves so far have accounted for.
*
* The travel is what decides a crossing, rather than where the row is drawn *now*: a lazy list
* animates an item into its new place, so for a few frames after a move `offset` still reports
* roughly the old one. Deciding from that offset re-decided the same crossing on every frame
* until the animation caught up, and a drag of two rows arrived six rows down.
*/
private var settled = 0f
private fun info(key: Any): LazyListItemInfo? =
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key }
private fun itemAt(index: Int): LazyListItemInfo? =
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
/**
* How far from where the list laid it out this row should be drawn -- zero for every row but
* the one in hand.
*
* Measured against where the row is laid out *now* rather than accumulated, which is what makes
* it self-correcting: a move, or a scroll under the finger, puts the row somewhere new, and the
* same subtraction cancels that out so the row stays under the finger instead of jumping by its
* own height.
*/
fun offsetOf(key: Any): Float {
if (key != held) return 0f
val now = info(key) ?: return 0f
return grabbedAt + dragged - now.offset
}
internal fun grab(key: Any) {
val from = info(key) ?: return
held = key
grabbedAt = from.offset
at = from.index
this.from = from.index
dragged = 0f
scrolled = 0f
settled = 0f
// The platform's "you have picked this up", the same feedback a long press gives, because
// the gesture it confirms is the same kind of commitment.
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
internal fun drag(by: Float) {
if (held == null) return
dragged += by
cross()
}
/**
* Trades places with as many neighbours as the travel so far has earned.
*
* Half a neighbour's height each way, so the row changes place when it covers most of the one
* it is passing -- and a full height of hysteresis before it can come back, since the move has
* already paid that half in the other direction. A loop rather than one step: a fast drag, or a
* list scrolling under a parked finger, crosses several rows between two events.
*/
private fun cross() {
while (true) {
val slack = dragged + scrolled - settled
val next = itemAt(if (slack > 0) at + 1 else at - 1) ?: return
if (abs(slack) < next.size / 2f) return
// Where the list is looking, taken before the move and put back after it. A lazy list
// keeps its place by the *key* of the item at the top, so moving that item takes the
// viewport with it -- drag the top row down two places and the list scrolls two rows
// to follow it, which reads as the row never having moved. The correction is by index,
// which is the thing that did not change.
val anchor = listState.firstVisibleItemIndex
val within = listState.firstVisibleItemScrollOffset
onMove.value(at, next.index)
// Requested rather than scrolled to: this has to take effect in the *same* measurement
// as the move, and a scroll launched beside it lands before the list has taken the new
// order and is then undone by it.
listState.requestScrollToItem(anchor, within)
settled += if (slack > 0) next.size.toFloat() else -next.size.toFloat()
at = next.index
// Loud on purpose: the row is under a finger that is covering it, so the tick is how
// the reader knows a place was taken rather than that they are still between two.
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
internal fun release() {
// Only where the row actually went somewhere: a handle pressed and let go has rearranged
// nothing, and reporting one would have the server rewrite the order it already has.
val moved = held != null && at != from
held = null
dragged = 0f
scrolled = 0f
settled = 0f
if (moved) onSettled.value()
}
/**
* Scrolls the list while the row in hand is held against one end of it, so a row can be moved
* further than one screenful. A frame loop rather than a response to the drag, because a finger
* parked at the bottom edge sends no more events and is exactly the case this exists for.
*/
internal fun follow() {
val key = held ?: return
scope.launch {
while (held == key) {
withFrameNanos {}
val moving = info(key) ?: continue
val viewport = listState.layoutInfo.viewportEndOffset
val edge = with(density) { EDGE.toPx() }
val top = grabbedAt + dragged
val bottom = top + moving.size
val step =
when {
top < edge -> -(edge - top).coerceAtMost(edge)
bottom > viewport - edge -> (bottom - (viewport - edge)).coerceAtMost(edge)
else -> 0f
}
if (step == 0f) continue
// Counted as travel of its own: the finger has not moved, but the rows have moved
// under it, which is the same thing to everything above. Nothing is added to the
// drag, because where the row is *drawn* is measured against the list's own
// offsets and those have already moved.
scrolled += listState.scrollBy(step * SPEED)
cross()
}
}
}
private companion object {
/** How close to an end of the list a held row has to be before the list follows it. */
val EDGE = 36.dp
/** A fraction of the overshoot per frame, so the scroll eases in rather than lurching. */
const val SPEED = 0.12f
}
}
@Composable
fun rememberReorder(
listState: LazyListState,
/** Two indices into the lazy list, which is the caller's own order to rearrange. */
onMove: (from: Int, to: Int) -> Unit,
/** The drag is over: the order on screen is the one to keep. */
onSettled: () -> Unit,
): Reorder {
val move = rememberUpdatedState(onMove)
val settled = rememberUpdatedState(onSettled)
val haptics = LocalHapticFeedback.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
return remember(listState) { Reorder(listState, scope, haptics, density, move, settled) }
}
/**
* The handle a row is dragged by: the burger, at about the size of a heading.
*
* Bigger than an icon beside a line of text -- this is what a row is taken hold of by, and at
* [GLYPH_SIZE] it read as decoration on the end of the row. Not as big as the row either: a mark
* scaled to the card's whole inner height came out heavier than anything else on screen, since
* these rules thicken with the glyph.
*
* The touch square around it is [GLYPH_BUTTON_SIZE], the same as every other icon control here, so
* the mark and the area that answers to a finger are two different sizes -- which is why the caller
* subtracts [HANDLE_MARGIN] from the gap it wants: what has to line up with the text on the other
* side is the mark, not the box around it.
*
* [key] is the row's own key in the list, which is how a gesture that started here finds the row it
* belongs to -- an index would be stale the moment the first move landed.
*/
@Composable
fun ReorderHandle(state: Reorder, key: Any, modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.size(GLYPH_BUTTON_SIZE)
// Nothing here draws a word, and a handle is the kind of control somebody using a
// screen reader has no other way to find.
.semantics { contentDescription = "Drag to reorder" }
.pointerInput(key) {
detectDragGestures(
onDragStart = {
state.grab(key)
state.follow()
},
onDrag = { _, amount -> state.drag(amount.y) },
onDragEnd = { state.release() },
onDragCancel = { state.release() },
)
},
) {
Glyph(DRAG_GLYPH, colour = MaterialTheme.colorScheme.onSurfaceVariant, size = HANDLE_MARK)
}
}
/** How big the mark itself is: a heading's size, which is what the font is asked for in `sp`. */
private val HANDLE_MARK = 24.sp
/**
* How much of the touch square lies outside the mark on each side.
*
* A caller that wants the *mark* a given distance from something takes this off that distance --
* see the rule about aligning the mark rather than the box it is centred in.
*/
val HANDLE_MARGIN = (GLYPH_BUTTON_SIZE - HANDLE_MARK.value.dp) / 2
@@ -1,89 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* The line under a finished reply: what it cost to produce, and when it was sent.
*
* Small and set back, in the tone the session's own subtitle takes: it is about the message rather
* than part of it, and at the reply's own size it would read as the last thing the model said.
*
* Right-aligned because it closes the message rather than opening one -- a reader scanning down the
* left edge is reading what was said, and this is where that ends.
*/
@Composable
fun ReplyFooter(
ts: Double,
tokensPerSecond: Double?,
prefillMs: Long?,
modifier: Modifier = Modifier,
) {
val text = replyFooterText(ts, tokensPerSecond, prefillMs, ZoneId.systemDefault()) ?: return
Text(
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = modifier.fillMaxWidth(),
)
}
/**
* What the footer says, or null when there is nothing to say: "read 9.5s · 50.3 tok/s · 3:00 PM".
*
* Split out so the wording is testable without a screen, and [zone] is a parameter for the same
* reason [limitSummary] takes one: a test has to say the same thing wherever it runs.
*
* **The time is last, and so sits against the right edge whatever else is on the line.** The
* measurements in front of it are the provider's, so a session on another provider has fewer of
* them or none -- and a reader who has learned where the clock is should not have to find it again
* because the model changed. The costs grow leftwards into the space instead.
*
* Those measurements are drawn only where the provider made them. Most do not -- a coding CLI
* reports what a turn cost and never how long the model spent on it -- and the time this app
* watched a reply arrive over is a different quantity: it counts the network, the pauses between
* tokens and whatever else the machine was doing. So the line is the clock alone rather than a
* plausible figure beside it.
*/
fun replyFooterText(
ts: Double,
tokensPerSecond: Double?,
prefillMs: Long?,
zone: ZoneId,
): String? {
val at =
if (ts <= 0.0) null
else
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone)
.format(Instant.ofEpochMilli((ts * 1000).toLong()))
} catch (_: Exception) {
null
}
// A tenth up to three digits, where the difference between 18 and 18.4 tok/s is something a
// reader comparing two models can use; past that the tenth is noise on a figure that moves by
// more than that between turns.
val rate =
tokensPerSecond
?.takeIf { it > 0.0 }
?.let {
if (it >= 100) String.format(Locale.getDefault(), "%.0f tok/s", it)
else String.format(Locale.getDefault(), "%.1f tok/s", it)
}
// Named "read" rather than given a unit alone, because a second figure in seconds beside a
// rate is unreadable otherwise -- and it is the same word the status row uses while it is
// happening, so the wait and the figure for it are one vocabulary.
val read = prefillMs?.takeIf { it > 0 }?.let { "read ${formatMillis(it)}" }
return listOfNotNull(read, rate, at).joinToString(" · ").ifEmpty { null }
}
@@ -31,13 +31,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
/** /**
* A session wanting attention, said over the app as well as in Android's drawer. * A session wanting attention, said over the app rather than through Android's drawer.
* *
* Two places carry the same fact and they are doing different jobs: a row in the shade waits * Two places can carry the same fact and only one is right at a time. A row in the shade is for
* however long it has to, which makes it the record, and a banner is read now or not at all, which * somebody looking at something else: it makes a sound, it waits however long it has to, and acting
* makes it the interruption. So somebody with the app open gets both -- this, and a silent row * on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
* behind it that is still there when they go looking and goes by itself when they open the session. * while these are on screen the stream is delivered here instead, which is arranged by the
* Whether the app is open at all is this collection and nothing else. * collection below and nothing else.
* *
* A banner can go three ways, each somebody deciding something different: tapped, which opens the * A banner can go three ways, each somebody deciding something different: tapped, which opens the
* session; pushed off either side; or left alone, in which case it goes when the bar runs out. * session; pushed off either side; or left alone, in which case it goes when the bar runs out.
@@ -5,32 +5,24 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
@@ -38,20 +30,12 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -156,23 +140,12 @@ fun SessionImageViewer(
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref) val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
val view = LocalView.current
var hiddenBars by remember(ref) { mutableStateOf(ViewerBars()) }
var barInsets by remember(ref) { mutableStateOf(ViewerBarInsets()) }
Dialog( Dialog(
onDismissRequest = onClose, onDismissRequest = onClose,
properties = properties = DialogProperties(usePlatformDefaultWidth = false),
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) { ) {
ViewerSystemBars(hiddenBars)
Box( Box(
Modifier.fillMaxSize() Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClose,
),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
when (val image = bitmap) { when (val image = bitmap) {
@@ -192,46 +165,7 @@ fun SessionImageViewer(
// beside it are. // beside it are.
CircularProgressIndicator(color = Color.White) CircularProgressIndicator(color = Color.White)
} }
else -> { else -> ZoomableImage(image)
var viewport by remember { mutableStateOf(IntSize.Zero) }
var nativeSizeRequest by remember { mutableIntStateOf(0) }
ZoomableImage(
image,
nativeSizeRequest = nativeSizeRequest,
onViewportChanged = {
viewport = it
ViewCompat.getRootWindowInsets(view)?.let { insets ->
barInsets =
ViewerBarInsets(
status =
insets
.getInsetsIgnoringVisibility(
WindowInsetsCompat.Type.statusBars()
)
.top,
navigation =
insets
.getInsetsIgnoringVisibility(
WindowInsetsCompat.Type.navigationBars()
)
.bottom,
)
}
},
onBarsChanged = { hiddenBars = it },
barInsets = barInsets,
viewport = viewport,
)
Button(
onClick = { nativeSizeRequest++ },
modifier =
Modifier.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(16.dp),
) {
Text("100%")
}
}
} }
} }
} }
@@ -291,72 +225,34 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality
* The image on its own, as large as it fits, with pinch to zoom. * The image on its own, as large as it fits, with pinch to zoom.
* *
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back * Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
* gesture returns to the transcript instead of leaving the app. It opens fitted, with the whole * gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
* image visible without enlarging a smaller one; the 100% control changes to one bitmap pixel per * visible.
* screen pixel and recenters it.
*/ */
@Composable @Composable
private fun ZoomableImage( private fun ZoomableImage(image: ImageBitmap) {
image: ImageBitmap,
nativeSizeRequest: Int,
onViewportChanged: (IntSize) -> Unit,
onBarsChanged: (ViewerBars) -> Unit,
barInsets: ViewerBarInsets,
viewport: IntSize,
) {
var scale by remember { mutableFloatStateOf(1f) } var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) } var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) }
val nativeScale = nativeScale(image.width, image.height, viewport.width, viewport.height)
LaunchedEffect(nativeSizeRequest, nativeScale) {
if (nativeSizeRequest > 0) {
scale = nativeScale
offsetX = 0f
offsetY = 0f
}
}
val bars =
viewerBars(
image.width,
image.height,
viewport.width,
viewport.height,
scale,
Offset(offsetX, offsetY),
barInsets,
)
SideEffect { onBarsChanged(bars) }
Image( Image(
bitmap = image, bitmap = image,
contentDescription = "Attached image", contentDescription = "Attached image",
contentScale = ContentScale.Inside, contentScale = ContentScale.Fit,
// Zoomed in, the reader is looking at pixels on purpose. // Zoomed in, the reader is looking at pixels on purpose.
filterQuality = FilterQuality.None, filterQuality = FilterQuality.None,
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.onSizeChanged(onViewportChanged) .pointerInput(Unit) {
.pointerInput(nativeScale) { detectTransformGestures { _, pan, zoom, _ ->
detectTransformGestures { centroid, pan, zoom, _ -> // Floor of 1 so the image cannot be pinched smaller than fitted, which is
val oldScale = scale // already the whole of it; a ceiling so it cannot be lost off-screen.
val maximumScale = maxOf(8f, nativeScale) scale = (scale * zoom).coerceIn(1f, 8f)
val newScale = (oldScale * zoom).coerceIn(1f, maximumScale) if (scale > 1f) {
if (newScale > 1f) { offsetX += pan.x
val offset = offsetY += pan.y
zoomOffset(
Offset(offsetX, offsetY),
centroid,
pan,
oldScale,
newScale,
Offset(size.width / 2f, size.height / 2f),
)
offsetX = offset.x
offsetY = offset.y
} else { } else {
offsetX = 0f offsetX = 0f
offsetY = 0f offsetY = 0f
} }
scale = newScale
} }
} }
.graphicsLayer { .graphicsLayer {
@@ -367,104 +263,3 @@ private fun ZoomableImage(
}, },
) )
} }
/** Lets the picture use the whole display, hiding only the system bars it actually reaches. */
@Composable
private fun ViewerSystemBars(hidden: ViewerBars) {
val view = LocalView.current
val window = (view.parent as? DialogWindowProvider)?.window
val controller = window?.let { WindowCompat.getInsetsController(it, view) }
SideEffect {
controller?.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
if (hidden.status) {
controller?.hide(WindowInsetsCompat.Type.statusBars())
} else {
controller?.show(WindowInsetsCompat.Type.statusBars())
}
if (hidden.navigation) {
controller?.hide(WindowInsetsCompat.Type.navigationBars())
} else {
controller?.show(WindowInsetsCompat.Type.navigationBars())
}
}
DisposableEffect(view) {
onDispose {
controller?.show(
WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()
)
}
}
}
internal data class ViewerBars(val status: Boolean = false, val navigation: Boolean = false)
internal data class ViewerBarInsets(val status: Int = 0, val navigation: Int = 0)
/** Which full-screen system-bar regions the fitted, zoomed and panned image intersects. */
internal fun viewerBars(
imageWidth: Int,
imageHeight: Int,
viewportWidth: Int,
viewportHeight: Int,
scale: Float,
offset: Offset,
insets: ViewerBarInsets,
): ViewerBars {
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) {
return ViewerBars()
}
val fittedScale = insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
val width = imageWidth * fittedScale * scale
val height = imageHeight * fittedScale * scale
val left = viewportWidth / 2f + offset.x - width / 2f
val right = left + width
val top = viewportHeight / 2f + offset.y - height / 2f
val bottom = top + height
val crossesScreen = right > 0f && left < viewportWidth
return ViewerBars(
status = crossesScreen && insets.status > 0 && bottom > 0f && top < insets.status,
navigation =
crossesScreen &&
insets.navigation > 0 &&
bottom > viewportHeight - insets.navigation &&
top < viewportHeight,
)
}
/** Scale relative to [ContentScale.Inside] at which bitmap and screen pixels are one-to-one. */
internal fun nativeScale(
imageWidth: Int,
imageHeight: Int,
viewportWidth: Int,
viewportHeight: Int,
): Float {
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) return 1f
return 1f / insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
}
/** The downscale-only factor used by [ContentScale.Inside]. */
private fun insideScale(
imageWidth: Int,
imageHeight: Int,
viewportWidth: Int,
viewportHeight: Int,
): Float =
minOf(
1f,
viewportWidth.toFloat() / imageWidth,
viewportHeight.toFloat() / imageHeight,
)
/** Keeps the image point beneath [centroid] beneath the fingers as its scale changes. */
internal fun zoomOffset(
offset: Offset,
centroid: Offset,
pan: Offset,
oldScale: Float,
newScale: Float,
viewportCenter: Offset,
): Offset {
val scaleChange = newScale / oldScale
return offset * scaleChange + (centroid - viewportCenter) * (1f - scaleChange) + pan
}
@@ -1,11 +1,9 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -14,15 +12,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -35,30 +29,14 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* The sessions tab: every session, in the order the reader has put them in. * The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
*
* Nothing here sorts. The order is the server's `sessions` list and the reader's own -- see
* [reorderSessions] -- which is the one arrangement a row cannot be moved out of by something the
* session does. It replaced sorting by activity, and then sorting by when each agent was turned on:
* both meant a list that rearranged itself under whoever was reading it, and the status word and
* its colour already say which session wants something without the row having to move to say it.
*
* Holding a row puts the screen in selection mode, the same gesture and the same bottom bar as the
* import tab, so the two lists are learned once. Rearranging is deliberately *not* part of a
* selection -- the handle moves the row it is on, whether or not that row is picked out -- because
* "which rows am I acting on" and "where does this one go" are two questions.
* *
* No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one. * No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
* What stays here is the button that adds a session, because that acts on this list and nothing * What stays here is the button that adds a session, because that acts on this list and nothing
@@ -70,23 +48,10 @@ fun SessionListScreen(
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
/** A session this list has just deleted, for whoever is showing it elsewhere. */
onDeleted: (String) -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) } var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Which rows the reader has picked out. Empty means selection mode is off, as on the import
// tab: a selection mode with nothing in it has no controls and no way out but Back.
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
// The sessions a delete has been confirmed for, or none. A list rather than one session,
// because a selection is what the bar below acts on.
var confirmingDelete by remember { mutableStateOf<List<SessionSummary>>(emptyList()) }
// Whether an answer is outstanding, which is a different question from whether there is
// anything to draw: see [refresh].
var reloading by remember { mutableStateOf(false) }
// Failures that belong to one session rather than to the list, keyed by its id and shown on its // Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused, // own card. The two scopes are decided by whether the server answered: it answered and refused,
@@ -95,10 +60,6 @@ fun SessionListScreen(
// Cleared on the next successful load below -- an entry outlives its session otherwise. // Cleared on the next successful load below -- an entry outlives its session otherwise.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) } var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Why the order on screen is not the order that was saved, when saving one failed. The list is
// what failed, so it is reported over the list rather than on any row.
var orderError by remember { mutableStateOf<String?>(null) }
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because // Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
// the rows are rebuilt from whatever the server last said and this belongs to the request. // the rows are rebuilt from whatever the server last said and this belongs to the request.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) } var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
@@ -109,142 +70,31 @@ fun SessionListScreen(
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
fun refresh() { fun refresh() {
// The rows stay while the answer is on its way, with the bar below saying one is: this listState = LoadState.Loading
// list is asked again every time the panel over a session is opened, and blanking it each
// time hands the reader an empty screen to report on something that was never in doubt.
// A first load has nothing to keep, and says so with the spinner instead.
if (listState !is LoadState.Loaded) listState = LoadState.Loading
reloading = true
scope.launch { scope.launch {
listState = listState =
try { try {
val loaded = val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap() deleteErrors = emptyMap()
val alive = loaded.value.map { it.id }.toSet()
// A selection is of sessions, so one deleted somewhere else leaves it. Only
// that one: the other rows the reader picked out are still there.
selected = selected.intersect(alive)
// The path out for a cached transcript whose session was deleted somewhere // The path out for a cached transcript whose session was deleted somewhere
// else. This list is the only place that ever learns the full set. On the // else. This list is the only place that ever learns the full set. On the
// answer rather than in `finally`: a list that failed to arrive says nothing // answer rather than in `finally`: a list that failed to arrive says nothing
// about which sessions exist. // about which sessions exist.
withContext(Dispatchers.IO) { transcriptCache.retainOnly(alive) } withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
}
loaded loaded
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
reloading = false
}
}
/**
* Deletes every session in [targets], one after another.
*
* One at a time and in the order they are drawn: the server has no batch delete for sessions,
* and each one ends a process. Each row says what is happening to it from the moment the work
* is handed over, which is also when the selection goes -- a bar still naming sessions being
* deleted is a set nobody can act on.
*/
fun deleteChosen(targets: List<SessionSummary>, alsoDeleteForeign: Boolean) {
selected = emptySet()
// Marked here rather than after the request returns: a row has to say something is
// happening to it from the moment it is asked for.
deleting = deleting + targets.map { it.id }
deleteErrors = deleteErrors - targets.map { it.id }.toSet()
scope.launch {
for (session in targets) {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the session
// exactly as it was, and its transcript with it.
transcriptCache.session(TranscriptAddress(session.id)).purge()
}
// Only this row, and only what changed. Refetching the list instead put every
// other session back through loading and handed the reader an empty screen, to
// report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState = LoadState.Loaded(loaded.value.filterNot { it.id == session.id })
}
onDeleted(session.id)
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the session it
// refused about is exactly as it was.
deleteErrors = deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
} }
} }
LaunchedEffect(reloadToken) { refresh() } LaunchedEffect(reloadToken) { refresh() }
val rows = rememberLazyListState()
val reorder =
rememberReorder(
listState = rows,
onMove = { from, to ->
// Moved here and now, because the row is under a finger: waiting for the server to
// agree would drag the handle away from the card it is on. What the server thinks
// is asked for when the finger comes up, and a refusal puts the list back.
val loaded = listState
if (loaded is LoadState.Loaded) {
val moved = loaded.value.toMutableList()
moved.add(to, moved.removeAt(from))
listState = LoadState.Loaded(moved)
}
},
onSettled = {
val loaded = listState
if (loaded is LoadState.Loaded) {
val order = loaded.value.map { it.id }
scope.launch {
try {
withContext(Dispatchers.IO) { reorderSessions(settings, order) }
orderError = null
} catch (e: ApiException) {
orderError = e.message ?: "The new order couldn't be saved"
// The screen must not go on showing an arrangement nothing kept, so
// the server's own order comes back -- which is also the only way to
// see what it does think.
refresh()
}
}
}
},
)
// Back leaves selection mode rather than the tab, which is the level it is one step above.
// Nested inside MainScreen's own handler, so it wins while there is a selection.
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
// can still be scrolled to while it is up.
var barHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
// What the bar covers *now*: its measurement is kept while it is away, but nothing is
// reserved for a bar that is not up.
val covered = if (selected.isEmpty()) 0.dp else barHeight
// The spawn button floats over the list, so the list ends above it -- measured, for the
// reason the bar is. Without this the last row sat under the button, which was survivable
// while every part of a row did the same thing and is not now that corner is a handle.
var buttonHeight by remember { mutableStateOf(0.dp) }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
orderError?.let { message ->
// The server's own words, unprefixed, the way every other failure is shown.
Text(
message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
}
when (val state = listState) { when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is already a whole // The message as Api.kt wrote it, with nothing added: it is already a whole
@@ -263,32 +113,20 @@ fun SessionListScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
LazyColumn( // Awaiting-answer first (the point of the screen), then most recently active.
state = rows, val ordered =
contentPadding = state.value.sortedWith(
PaddingValues(bottom = covered + buttonHeight + BUTTON_RING * 2), compareByDescending<SessionSummary> { it.status == "awaitingInput" }
) { .thenByDescending { it.lastActivity }
uniqueItems(state.value, key = { it.id }) { session -> )
LazyColumn {
uniqueItems(ordered, key = { it.id }) { session ->
SessionCard( SessionCard(
session = session, session = session,
error = deleteErrors[session.id], error = deleteErrors[session.id],
deleting = session.id in deleting, deleting = session.id in deleting,
picked = session.id in selected, onOpen = { onOpen(session) },
// The handle is a selection-mode control, so it is absent rather onLongPress = { confirmingDelete = session },
// than disabled outside one: this is not a capability being
// withheld, it is a mode the list is not in.
reorder = reorder.takeIf { selected.isNotEmpty() },
onClick = {
// In selection mode a tap is a selection, so the reader is
// never one mis-tap away from opening a session they were only
// picking rows for.
if (selected.isEmpty()) onOpen(session)
else
selected =
if (session.id in selected) selected - session.id
else selected + session.id
},
onLongPress = { selected = selected + session.id },
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
@@ -297,114 +135,70 @@ fun SessionListScreen(
} }
} }
// Over the list rather than above it: a bar that appears in the flow moves every row down
// by its own height at the moment the reader is looking at them.
if (reloading) {
LinearProgressIndicator(Modifier.align(Alignment.TopCenter).fillMaxWidth())
}
// Beside nothing in particular, because a selection is not one row: the options that act on
// it belong to the screen, and the bottom is where a thumb already is.
if (selected.isNotEmpty()) {
val picked =
(listState as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
SessionSelectionBar(
count = picked.size,
modifier =
Modifier.align(Alignment.BottomCenter).onSizeChanged {
barHeight = with(density) { it.height.toDp() }
},
onDelete = { confirmingDelete = picked },
)
}
// Above the bar when there is one, by what that bar measured: the button stays rather than
// coming and going, since an absent control cannot say whether there was nothing to do.
FloatingActionButton( FloatingActionButton(
onClick = onSpawn, onClick = onSpawn,
modifier = modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
Modifier.align(Alignment.BottomEnd)
.padding(end = BUTTON_RING, bottom = BUTTON_RING + covered)
.onSizeChanged { buttonHeight = with(density) { it.height.toDp() } },
) { ) {
Text("+", style = MaterialTheme.typography.headlineMedium) Text("+", style = MaterialTheme.typography.headlineMedium)
} }
} }
val targets = confirmingDelete confirmingDelete?.let { session ->
if (targets.isNotEmpty()) { // Reset per session, so a toggle turned on for one conversation is not still on for the
// Reset per selection, so a toggle turned on for one set of conversations is not still // next. Off to begin with: see [deleteSession].
// on for the next. Off to begin with: see [deleteSession]. var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
var alsoDeleteForeign by remember(targets) { mutableStateOf(false) }
// Whichever of these keep a transcript of their own decide what the sentences below say,
// and whether the switch is offered at all. Old servers reported only the capability, when
// Claude Code was its sole owner.
val owned = targets.filter { it.keepsOwnTranscript }
val transcriptOwner = owned.firstOrNull()?.ownTranscriptName ?: "Claude Code"
AlertDialog( AlertDialog(
onDismissRequest = { confirmingDelete = emptyList() }, onDismissRequest = { confirmingDelete = null },
title = { title = { Text("Delete \"${session.title}\"?") },
Text(
if (targets.size == 1) "Delete \"${targets.first().title}\"?"
else "Delete ${targets.size} sessions?"
)
},
text = { text = {
// Two different acts behind one button, so it says which one this is. What // Two different acts behind one button, so it says which one this is. What
// separates them is whether the *driver* keeps its own record of the // separates them is whether the *driver* keeps its own record of the conversation
// conversation // -- the Claude Code CLI does, whether this app spawned the session or imported it;
// -- the coding CLIs do, whether this app spawned the session or imported it;
// echo and llama.cpp do not. // echo and llama.cpp do not.
// //
// This used to branch on `imported`, above a comment asserting that "a session // This used to branch on `imported`, above a comment asserting that "a session
// started here has no copy anywhere". That was false for every coding-CLI session // started here has no copy anywhere". That was false for every claude-cli session
// this app spawned, and getting it wrong in that direction is the expensive one: // this app spawned, and getting it wrong in that direction is the expensive one:
// "this can't be undone", said of something that can, spends the credibility that // "this can't be undone", said of something that can, spends the credibility the
// sentence needs. // sentence needs.
// //
// Neither branch promises a restore. The recoverable one says what is known, // Neither branch promises a restore. The recoverable one says what is known -- the
// that the driver keeps its own record, rather than that the file is still there, // driver keeps its own record -- rather than that the file is still there, and it
// and it names what goes either way, because this app's transcript holds images, // names what goes either way, because this app's transcript holds images, peer
// peer messages and commands the CLI's own record never had. // messages and commands the CLI's own record never had.
//
// A selection takes the sentence that covers all of it: "some of these" is what
// makes the mixed case true without either half of it being read as a promise
// about every row.
Column { Column {
Text( Text(
when { when {
owned.isEmpty() -> !session.keepsOwnTranscript ->
"Kills the process and deletes the conversation. Nothing else " + "Kills the process and deletes the conversation. Nothing else " +
"keeps a copy, so this can't be undone." "keeps a copy, so this can't be undone."
// The sentence below is the one the toggle makes false, which is // The sentence below is the one the toggle makes false, which is why it
// why it is written twice rather than appended to: "should still be // is written twice rather than appended to: leaving "should still be
// there to import again", left on screen beside a switch that removes // there to import again" on screen beside a switch that removes it is
// it, is the reassurance being read as it stops being true. // the reassurance being read at the moment it stops being true.
alsoDeleteForeign -> alsoDeleteForeign ->
"Kills the process and deletes both copies of the conversation: " + "Kills the process and deletes both copies of the conversation: " +
"this app's, and $transcriptOwner's own transcript on the " + "this app's, and Claude Code's own transcript on the " +
"machine. Nothing keeps another, so this can't be undone." "machine. Nothing keeps another, so this can't be undone."
else -> else ->
"Stops the process and deletes this app's copy of the " + "Stops the process and deletes this app's copy of the " +
"conversation, including any images, peer messages and " + "conversation, including any images, peer messages and " +
"commands recorded only here. $transcriptOwner keeps its own " + "commands recorded only here. Claude Code keeps its own " +
"transcript on the machine" + "transcript on the machine, so the conversation itself " +
(if (owned.size < targets.size) " for some of these" else "") + "should still be there to import again."
", so the conversation itself should still be there to " +
"import again."
} }
) )
// Only where there is a second copy to decide about. Absent rather than // Only where there is a second copy to decide about. Absent rather than
// disabled, because this is not a capability being withheld: for echo and // disabled, because this is not a capability being withheld: for echo and
// llama.cpp there is no other transcript, and a switch offering to delete one // llama.cpp there is no other transcript, and a switch offering to delete one
// would be asking about something that does not exist. // would be asking about something that does not exist.
if (owned.isNotEmpty()) { if (session.keepsOwnTranscript) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Its own row rather than beside the paragraph: a switch is taller // Its own row rather than beside the paragraph: a switch is taller than a
// than a line of text and re-centres whatever shares a row with it. // line of text and re-centres whatever shares a row with it.
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"Delete $transcriptOwner's transcript too", "Delete Claude Code's transcript too",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -420,8 +214,38 @@ fun SessionListScreen(
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
confirmingDelete = emptyList() confirmingDelete = null
deleteChosen(targets, alsoDeleteForeign) // Marked here rather than after the request returns: the row has to say
// something is happening to it from the moment it is asked for.
deleting = deleting + session.id
deleteErrors = deleteErrors - session.id
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it.
transcriptCache.session(session.id).purge()
}
// Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the
// reader an empty screen, to report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState =
LoadState.Loaded(
loaded.value.filterNot { it.id == session.id }
)
}
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the
// session it refused about is exactly as it was.
deleteErrors =
deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
} }
) { ) {
// Coloured by consequence: this takes something away, and does so wherever it // Coloured by consequence: this takes something away, and does so wherever it
@@ -430,54 +254,12 @@ fun SessionListScreen(
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { confirmingDelete = emptyList() }) { Text("Cancel") } TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
}, },
) )
} }
} }
/**
* The ring of space inside a session's card, which is also what its handle leaves around itself.
*/
private val CARD_PADDING = 16.dp
/** The gap the spawn button keeps from the edges it floats over, and from the list above it. */
private val BUTTON_RING = 24.dp
/**
* What can be done to the sessions that are selected.
*
* Delete only, for now, which is the one thing this screen has ever done to a session from the list
* rather than from inside it. The same bar as the import tab's, down to the wording of the count.
*/
@Composable
private fun SessionSelectionBar(
count: Int,
modifier: Modifier = Modifier,
onDelete: () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 3.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
"$count selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDelete) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SessionCard( private fun SessionCard(
@@ -492,118 +274,62 @@ private fun SessionCard(
* about a request that has not been answered yet. * about a request that has not been answered yet.
*/ */
deleting: Boolean, deleting: Boolean,
/** Whether this row is one of the selection the bottom bar acts on. */ onOpen: () -> Unit,
picked: Boolean,
/** The drag this row can be moved by, or null where the list is not in selection mode. */
reorder: Reorder?,
onClick: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit,
) { ) {
val held = reorder?.held == session.id
BusyItem(label = if (deleting) "deleting" else null) { BusyItem(label = if (deleting) "deleting" else null) {
Card( Card(
colors = // Off while the delete is in flight: a card that still opens a session it is deleting
if (picked) // is a race the reader can start by tapping. On the card rather than in [BusyItem],
CardDefaults.cardColors( // which leaves gestures alone so the list still scrolls.
containerColor = MaterialTheme.colorScheme.secondaryContainer, Modifier.fillMaxWidth()
contentColor = MaterialTheme.colorScheme.onSecondaryContainer, .combinedClickable(
) enabled = !deleting,
else CardDefaults.cardColors(), onClick = onOpen,
// Lifted while it is in hand, which is the one cue that says this row is being carried onLongClick = onLongPress,
// rather than sitting where it belongs. )
elevation = CardDefaults.cardElevation(defaultElevation = if (held) 8.dp else 0.dp),
modifier =
Modifier.fillMaxWidth()
// Drawn where the finger has taken it, above the rows it is passing over. Both
// in the layer rather than in the layout, so nothing around it moves and the
// list does not remeasure per frame of a drag.
.zIndex(if (held) 1f else 0f)
.graphicsLayer { translationY = reorder?.offsetOf(session.id) ?: 0f },
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.padding(16.dp)) {
Column( Row(
// Everything but the handle, which is what makes the two gestures separate verticalAlignment = Alignment.CenterVertically,
// rather than competing: a press that lands on the handle never reaches this, modifier = Modifier.fillMaxWidth(),
// so holding it cannot select the row it is about to move. The card had the
// click while the handle was the only thing inside it that did not want one,
// and a hold on the handle then both selected the row and ate the drag.
//
// Off while the delete is in flight: a card that still opens a session it is
// deleting is a race the reader can start by tapping. Here rather than in
// [BusyItem], which leaves gestures alone so the list still scrolls.
Modifier.weight(1f)
.combinedClickable(
enabled = !deleting,
onClick = onClick,
onLongClick = onLongPress,
)
.padding(CARD_PADDING)
) { ) {
Row( Text(
verticalAlignment = Alignment.CenterVertically, session.title,
modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.titleMedium,
) { modifier = Modifier.weight(1f),
Text( )
session.title, StatusText(session.status)
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
StatusText(session.status)
if (session.backgroundTasks > 0) {
Text(
backgroundTaskLabel(session.backgroundTasks),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
// Machine, then what runs on it, then what it is set to: the same order
// and separator as the session screen's header and the usage dialog, so
// one pair of facts is not written three ways.
listOfNotNull(
session.machineName,
session.provider,
session.model?.let { modelLabel(it) },
)
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(session.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
error?.let {
Spacer(Modifier.height(8.dp))
// The server's own words, unprefixed, the way every other failure is shown.
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
} }
// Inside the card, so what it moves is the thing it is drawn on. Nothing is held Spacer(Modifier.height(4.dp))
// open for it outside selection mode: the row is then the row it always was. Row(modifier = Modifier.fillMaxWidth()) {
if (reorder != null) { Text(
ReorderHandle( // Machine, then what runs on it, then what it is set to: the same order and
reorder, // separator as the session screen's header and the usage dialog, so one
session.id, // pair of facts is not written three ways.
// Dimmed with the rest of the row while something is happening to it, since listOfNotNull(
// a row on its way out is not one to rearrange -- see [BusyItem], whose session.setupName,
// appearance this matches rather than repeating its dimming rule. session.provider,
// The mark lines up with the text on the other side of the card, session.model?.let { modelLabel(it) },
// which means taking the square it is centred in off the gap: see )
// [HANDLE_MARGIN]. .joinToString(" · "),
Modifier.alpha(if (deleting) 0.4f else 1f) style = MaterialTheme.typography.bodySmall,
.padding(end = CARD_PADDING - HANDLE_MARGIN), color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(session.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
error?.let {
Spacer(Modifier.height(8.dp))
// The server's own words, unprefixed, the way every other failure is shown.
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
) )
} }
} }
@@ -613,8 +339,18 @@ private fun SessionCard(
@Composable @Composable
fun StatusText(status: String) { fun StatusText(status: String) {
val label = sessionStatusWord(status) val (label, color) =
val color = sessionStatusColour(status) when (status) {
"awaitingInput" -> "your turn" to awaitingColor
"running" -> "running" to runningColor
"compacting" -> "compacting" to commandColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree: the
// session is not idle and has not exited, nobody has been able to find out which. A
// muted colour alone would read as one of the quiet states.
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (sessionWorking(status)) { if (sessionWorking(status)) {
// The same colour as the word beside it: the two are one signal, and a spinner in the // The same colour as the word beside it: the two are one signal, and a spinner in the
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,331 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* What can be changed about one session, as opposed to about this app.
*
* Over the session rather than a step down from it: everything here is about the conversation
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
* controls and hid the thing they act on.
*
* The model and the permission mode are deliberately still on the session's own bar, because those
* are changed *while* reading a turn -- "not this model, try that one".
*
* Captions are for what a control costs rather than for what it is. A paragraph under every control
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
* and Reload do, because what those two take away is not visible from here.
*/
@Composable
fun SessionSettingsDialog(
settings: ServerSettings,
sessionId: String,
/**
* What the session is called now, as the screen behind this knows it -- see the rename below.
*/
title: String,
onRenamed: (String) -> Unit,
/**
* What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it.
*/
cachedBytes: Long?,
onReload: () -> Unit,
onDismiss: () -> Unit,
/**
* Copies what this session costs to draw. Built by the session screen, because everything it
* measures is that screen's own state.
*/
onCopyRenderReport: () -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
// whenever the list was last fetched, so drawing the switch straight from it would show a
// position that may have been changed since. Until the answer arrives the switch is disabled
// and a spinner sits beside it, which is what not knowing looks like.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the switch
// above is. An empty answer is a session that was never given a directory, which is not the
// same as one whose directory is unknown -- the field is only enabled once one of those is
// settled.
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
var typedCwd by remember(sessionId) { mutableStateOf("") }
var cwdError by remember { mutableStateOf<String?>(null) }
var movingCwd by remember { mutableStateOf(false) }
LaunchedEffect(sessionId) {
try {
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
notify = fresh.notify
cwd = fresh.cwd.orEmpty()
typedCwd = fresh.cwd.orEmpty()
} catch (e: ApiException) {
// Left unknown rather than falling back to the stale row: the switch stays disabled,
// instead of offering a position nothing confirmed.
notifyError = e.message
notify = null
}
}
/**
* Moves the session, which ends the process that is in the old directory.
*
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
* process, and a stopped session is a state this app already has a word and a button for.
*/
fun moveCwd() {
val chosen = typedCwd.trim()
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
movingCwd = true
cwdError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
cwd = chosen
} catch (e: ApiException) {
// Where it happened: this field is the only thing on screen that knows a move was
// asked for, and the reason is usually the path itself.
cwdError = e.message
} finally {
movingCwd = false
}
}
}
// Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
// and one that stays moved after a refusal lies.
fun setNotify(wanted: Boolean) {
val was = notify
notify = wanted
notifyError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
} catch (e: ApiException) {
notify = was
notifyError = e.message
}
}
}
// Nothing to do when the name has not changed, so the button says so rather than sending a
// request whose success would look exactly like the failure of having typed nothing.
val changed = name.trim().isNotEmpty() && name.trim() != title
fun save() {
if (!changed || saving) return
val chosen = name.trim()
saving = true
error = null
scope.launch {
try {
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
onRenamed(chosen)
} catch (e: ApiException) {
// Reported here, where it happened, because this dialog is the only place that
// knows a rename was attempted.
error = e.message
saving = false
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Session settings") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !saving,
modifier = Modifier.fillMaxWidth(),
// The keyboard's own action does what the button does: a one-field form where
// the return key does nothing is a form people press return at anyway.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { save() }),
)
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Notifications", modifier = Modifier.weight(1f))
if (notify == null && notifyError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = notify == true,
onCheckedChange = { setNotify(it) },
enabled = notify != null,
)
}
// Beside the switch that failed, not with the rename's error: they are two requests
// and a reader has to be able to tell which one the server refused.
notifyError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = typedCwd,
onValueChange = { typedCwd = it },
label = { Text("Working directory") },
// What the field cannot say by being empty: a session that was never given
// one starts wherever its launcher does, and this names that rather than
// showing a path nobody chose.
placeholder = { Text("wherever the session was started") },
singleLine = true,
enabled = cwd != null && !movingCwd,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
)
TextButton(
onClick = { moveCwd() },
enabled =
cwd != null &&
!movingCwd &&
typedCwd.trim().isNotEmpty() &&
typedCwd.trim() != cwd,
) {
Text(if (movingCwd) "Moving..." else "Move")
}
}
// The whole of what pressing Move does, where it is about to be pressed. A
// directory is settled when the process is spawned, so it is ended and the next
// thing said to the session starts it in the new place.
Text(
"Moving stops the session's process. It starts again in the new directory " +
"with the next message, or with Start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
cwdError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Transcript", modifier = Modifier.weight(1f))
// The size is what the button discards, and the unknown state is drawn rather
// than guessed: a spinner while the directory is being measured, and words when
// there is nothing there, because "nothing cached" and "0 B" read as different
// claims.
when {
cachedBytes == null ->
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
else ->
Text(
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(12.dp))
// Enabled whether or not anything is cached: "what I see disagrees with the
// machine" is a state an empty cache can be in too, and a control that comes
// and goes makes its own presence the signal.
TextButton(onClick = onReload) { Text("Reload") }
}
// Captioned, unlike the controls above it, for the same reason Move is: what it
// costs is not visible, and neither is the case it exists for.
Text(
"Reload throws away this phone's copy and fetches the transcript from the " +
"server again. Use it when what is shown here disagrees with the file " +
"on the machine.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
// About this session, which is what everything in here is -- and it was on the
// header until 2026-09-03, where the folder button now is. It copies rather than
// opening anything, so it says so and then says it happened: a row that looks like
// a control and gives no sign of having run is one people press twice.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Render timings", modifier = Modifier.weight(1f))
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
}
}
},
// Disabled rather than absent while there is nothing to save: a button that comes and goes
// makes its own presence the signal, and its absence cannot say why.
confirmButton = {
TextButton(onClick = { save() }, enabled = changed && !saving) {
Text(if (saving) "Saving..." else "Save")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
@@ -1,683 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
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.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* What can be changed about one session, and about the provider serving it.
*
* A screen rather than a dialog, again, and for the reason the dialog was chosen in the first place
* turned around: it has outgrown one. A Material dialog constrains its own height and scrolls
* inside itself, so a form of a dozen settings is read through a letterbox that also covers the
* conversation it is about -- and there is nowhere in it to put a second tab. Drawn over the
* session rather than as a `Screen` of its own, so the session under it stays composed and its
* stream keeps flowing; the back gesture closes it.
*
* **Two tabs, and the second is not a copy.** It is [ProviderScreen] -- the same composable the
* machines tab opens, for this session's machine and provider. A session's settings and its
* provider's are different things with different owners (one rides on a request, one decides how a
* model is loaded for everybody), and this is the second way in rather than a second version of
* them.
*
* The model and the permission mode are on the session's own bar as well, because those are changed
* *while* reading a turn -- "not this model, try that one". They are here too because that bar is
* one row shared with three actions: a long model name leaves the other picker a few pixels wide,
* and this is where somebody goes looking for a setting anyway.
*
* Captions are for what a control costs rather than for what it is. A paragraph under every control
* 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 SessionSettingsScreen(
settings: ServerSettings,
sessionId: String,
/** Which machine and provider the second tab is about. */
machineId: String,
provider: String,
/**
* What the session is called now, as the screen behind this knows it -- see the rename below.
*/
title: String,
onRenamed: (String) -> Unit,
/**
* How hard the model thinks, or null for the CLI's own default.
*
* Owned by the screen behind this rather than held here, like [title]: this dialog is what
* changes it, and a level kept only for as long as the dialog is open is the old one again the
* next time it is opened.
*
* Not fetched, because unlike the notification switch there is nothing else that changes it:
* the level is this app's to set and the server does not resolve it into something else.
*/
effort: String?,
onEffortChanged: (String?) -> Unit,
/** Whether a level does anything here; the row is left out entirely where it does not. */
takesEffort: Boolean,
/**
* The settings that are one of a list -- the model and the permission mode.
*
* Owned by the screen behind this, like [title] and [effort]: it is what asked the machine what
* the provider offers. Whichever of them this one has no answer for is not in the list, and
* draws no row.
*/
choices: List<SessionChoice>,
/**
* The settings this session's provider takes, and what they are set to.
*
* Declared by the server rather than listed here -- see [ProviderParamFields]. Empty for a
* provider with none, which draws no section at all.
*/
paramSpecs: List<ParamSpec>,
params: Map<String, String>,
onParamsChanged: (Map<String, String>) -> Unit,
/**
* What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it.
*/
cachedBytes: Long?,
/**
* How big the record on the server is, or null where it did not say. The other half of the pair
* beside it: what the conversation costs there, against what this phone is holding of it.
*/
transcriptBytes: Long?,
onReload: () -> Unit,
/**
* Opens the transcript file itself in the explorer. Null from a server that does not say where
* it is, which draws no button rather than one that cannot work.
*/
onViewRaw: (() -> Unit)?,
onDismiss: () -> Unit,
/**
* Copies what this session costs to draw. Built by the session screen, because everything it
* measures is that screen's own state.
*/
onCopyRenderReport: () -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) }
var effortError by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
// whenever the list was last fetched, so drawing the switch straight from it would show a
// position that may have been changed since. Until the answer arrives the switch is disabled
// and a spinner sits beside it, which is what not knowing looks like.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) }
// The same three-state shape the notification switch has, for the same reason: until the
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
// When the server next intends to ask whether the limit has lifted, or null when nothing is
// waiting. Read once with everything else: it moves on the server's schedule, not this
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
// told.
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
var resumeError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the switch
// above is. An empty answer is a session that was never given a directory, which is not the
// same as one whose directory is unknown -- the field is only enabled once one of those is
// settled.
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
var typedCwd by remember(sessionId) { mutableStateOf("") }
var cwdError by remember { mutableStateOf<String?>(null) }
var movingCwd by remember { mutableStateOf(false) }
LaunchedEffect(sessionId) {
try {
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
notify = fresh.notify
autoResume = fresh.autoResume
resumeMessage = fresh.autoResumeMessage
resumeAt = fresh.resumeAt
cwd = fresh.cwd.orEmpty()
typedCwd = fresh.cwd.orEmpty()
} catch (e: ApiException) {
// Left unknown rather than falling back to the stale row: the switch stays disabled,
// instead of offering a position nothing confirmed.
notifyError = e.message
notify = null
resumeError = e.message
autoResume = null
}
}
/**
* Moves the session, which ends the process that is in the old directory.
*
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
* process, and a stopped session is a state this app already has a word and a button for.
*/
fun moveCwd() {
val chosen = typedCwd.trim()
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
movingCwd = true
cwdError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
cwd = chosen
} catch (e: ApiException) {
// Where it happened: this field is the only thing on screen that knows a move was
// asked for, and the reason is usually the path itself.
cwdError = e.message
} finally {
movingCwd = false
}
}
}
/**
* Chooses a thinking level, which ends the process the old level was launched with.
*
* Put back if the request is refused, for the reason the notification switch below gives: a
* control that stays where it was put after a refusal is stating something untrue.
*/
fun setEffort(chosen: String?) {
val was = effort
onEffortChanged(chosen)
effortError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
} catch (e: ApiException) {
onEffortChanged(was)
effortError = e.message
}
}
}
// Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
// and one that stays moved after a refusal lies.
fun setNotify(wanted: Boolean) {
val was = notify
notify = wanted
notifyError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
} catch (e: ApiException) {
notify = was
notifyError = e.message
}
}
}
/**
* Turns auto-resume on or off, or changes what it would say.
*
* One request for both, because the server takes one: switching it on and typing the message
* are two halves of the same decision, and sending them separately would leave a moment where
* the session is armed with the old words.
*
* Put back if refused, like the notification switch. Turning it off also clears what was
* scheduled -- said here rather than only on the server, or the row would go on naming a time
* that no longer exists.
*/
fun setAutoResume(on: Boolean, message: String) {
val wasOn = autoResume
val wasMessage = resumeMessage
val wasAt = resumeAt
autoResume = on
resumeMessage = message
if (!on) resumeAt = null
resumeError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
setSessionAutoResume(settings, sessionId, on, message)
}
} catch (e: ApiException) {
autoResume = wasOn
resumeMessage = wasMessage
resumeAt = wasAt
resumeError = e.message
}
}
}
// Nothing to do when the name has not changed, so the button says so rather than sending a
// request whose success would look exactly like the failure of having typed nothing.
val changed = name.trim().isNotEmpty() && name.trim() != title
fun save() {
if (!changed || saving) return
val chosen = name.trim()
saving = true
error = null
scope.launch {
try {
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
onRenamed(chosen)
} catch (e: ApiException) {
// Reported here, where it happened, because this dialog is the only place that
// knows a rename was attempted.
error = e.message
saving = false
}
}
}
// The platform's own way back out of a layer: without it, back falls through to whatever is
// under this and closes the session -- which reads as a crash to somebody who meant to return
// to what they were reading.
BackHandler(onBack = onDismiss)
var tab by remember(sessionId) { mutableIntStateOf(0) }
Surface(Modifier.fillMaxSize()) {
// The keyboard covers the lower half of a form of fields, and this is a screen rather
// than a dialog now -- nothing else is going to move it out of the way.
Column(Modifier.fillMaxSize().imePadding()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
GlyphButton(BACK_GLYPH, "Back", onDismiss)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Text(
"Settings",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
// 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.
TextButton(onClick = { save() }, enabled = changed && !saving) {
Text(if (saving) "Saving..." else "Save")
}
}
// The same two-tab shape the main screen uses for its three, so a reader who has
// learned one has learned the other.
TabRow(selectedTabIndex = tab) {
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Session") })
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(provider) })
}
if (tab == 1) {
// The machines tab's own screen, with its back control left off: this one has a
// header of its own, and two ways out stacked above each other is a reader asking
// which of them goes where.
ProviderScreen(
settings = settings,
machineId = machineId,
provider = provider,
onBack = null,
)
return@Column
}
Column(
Modifier.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(top = 12.dp, bottom = 16.dp)
) {
LabelledField(
label = "Name",
value = name,
onValueChange = { name = it },
enabled = !saving,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
// 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.
keyboardActions = KeyboardActions(onDone = { save() }),
)
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Notifications", modifier = Modifier.weight(1f))
if (notify == null && notifyError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = notify == true,
onCheckedChange = { setNotify(it) },
enabled = notify != null,
)
}
// Beside the switch that failed, not with the rename's error: they are two requests
// and a reader has to be able to tell which one the server refused.
notifyError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
if (autoResume == null && resumeError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = autoResume == true,
onCheckedChange = { setAutoResume(it, resumeMessage) },
enabled = autoResume != null,
)
}
// Disabled rather than hidden while the switch is off: a field that comes and goes
// makes its own presence the signal, and a visible one teaches what the switch will
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
// sentence is one request instead of one per letter.
LabelledField(
label = "Message to send",
value = resumeMessage,
onValueChange = { resumeMessage = it },
// What an empty field means, said above it: the server's own word rather than
// a session poked with nothing to read.
hint = DEFAULT_RESUME_MESSAGE,
enabled = autoResume == true,
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))
// The button sits at the bottom of the row rather than centred on it: the field
// beside it is a label above a box, and a control centred against the pair lands
// beside the label rather than beside the thing it acts on.
Row(
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth(),
) {
LabelledField(
label = "Working directory",
value = typedCwd,
onValueChange = { typedCwd = it },
// 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.
hint = "wherever the session was started",
enabled = cwd != null && !movingCwd,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
modifier = Modifier.weight(1f),
)
TextButton(
onClick = { moveCwd() },
enabled =
cwd != null &&
!movingCwd &&
typedCwd.trim().isNotEmpty() &&
typedCwd.trim() != cwd,
) {
Text(if (movingCwd) "Moving..." else "Move")
}
}
// The whole of what pressing Move does, where it is about to be pressed. A
// directory is settled when the process is spawned, so it is ended and the next
// thing said to the session starts it in the new place.
Text(
"Moving stops the session's process. It starts again in the new directory " +
"with the next message, or with Start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
cwdError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
choices.forEach { choice ->
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(choice.label, modifier = Modifier.weight(1f))
PickerButton(
current = choice.current,
options = choice.options,
onPick = choice.onPick,
)
}
}
// Left out rather than disabled, the one place this dialog does that: a disabled
// 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 = effort ?: 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,
)
}
}
if (paramSpecs.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
Text(
"Model settings",
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
// Edited here and saved by the screen behind this, which is what makes
// typing in a text field affordable: the save is debounced, and a dialog
// dismissed mid-edit would take an unsaved value with it.
ProviderParamFields(
specs = paramSpecs,
values = params,
onChange = onParamsChanged,
warnAboutRestart = true,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Transcript", modifier = Modifier.weight(1f))
// What the conversation costs on the server, and then what Reload would
// discard here -- one line, so the two sizes read as a pair. A server that
// did not measure its file leaves its half out rather than saying zero.
val onServer = transcriptBytes?.let { humanSize(it) ?: "0 B" }
// The unknown state is drawn rather than guessed: a spinner while the cache
// is being measured, and words when there is nothing in it, because "nothing
// cached" and "0 B" read as different claims.
when {
cachedBytes == null -> {
onServer?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
}
else -> {
val cached =
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached"
Text(
onServer?.let { "$it · $cached" } ?: cached,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Both on a line of their own under what they act on, rather than crowded against
// the size on the line above: two buttons and a measurement do not fit the width
// of a phone, and the one that would lose is the number.
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// The record as it is on disk, for the question the drawn conversation cannot
// answer -- which is most of what anybody opens this dialog to debug.
onViewRaw?.let { TextButton(onClick = it) { Text("View raw") } }
Spacer(Modifier.width(8.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") }
}
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
// About this session, which is what everything in here is -- and it was on the
// header until 2026-09-03, where the folder button now is. It copies rather than
// opening anything, so it says so and then says it happened: a row that looks like
// a control and gives no sign of having run is one people press twice.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Render timings", modifier = Modifier.weight(1f))
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
}
}
}
}
}
/**
* One session setting that is a choice from a list, as this dialog draws it.
*
* A shape rather than a pair of parameters each, because a provider may offer either of them, both
* or neither, and they are otherwise the same control.
*/
data class SessionChoice(
val label: String,
val current: String,
val options: List<String>,
val onPick: (String) -> Unit,
)
/**
* When the server will next look, as a local time.
*
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
* is looking at it.
*/
private fun formatCheckTime(epochSeconds: Double): String =
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
} catch (_: Exception) {
// A time that cannot be read is not a time to show: the sentence above still says a check
// is coming, which is the part the reader can act on.
"soon"
}
@@ -1,74 +0,0 @@
package com.example.aiapp
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* What a session's status is called on screen, and what colour it is drawn in.
*
* One pair of functions rather than a branch on each screen that shows a status. There were two,
* and the second silently fell short the moment the server grew a state: `waiting` arrived and the
* session list learned the word and the colour while the session screen's status row printed the
* wire's own word in the muted grey every quiet state uses. That comment already said the words
* were "the session list's own"; this is what makes that true rather than a promise.
*
* A subagent's own three states are deliberately not here -- see `subagentStatusLabel`, which
* collapses everything it does not recognise rather than passing it through, because a subagent has
* fewer states than a session and reporting one it cannot have is worse than reporting none.
*/
fun sessionStatusWord(status: String, subagent: Boolean = false): String =
when (status) {
"idle" -> "idle"
"running" -> "running"
"compacting" -> "compacting"
// Not "running": a model coming off disk is not a model answering, and the difference is
// minutes. Said in its own word so a first message that waits is explained rather than
// looking like a session that has stopped responding. See `SessionStatus::Loading`.
//
// "model" rather than "loading" alone, because there are two waits before an answer and
// the reader is entitled to know which one they are in: this one happens once, and
// "reading prompt" below happens on every turn.
"loading" -> "loading model"
// The model has the prompt and has not started answering. Its own word for the same
// reason: a long conversation spends real time here, and reported as "running" it looked
// like a model thinking. See `SessionStatus::Reading`.
"reading" -> "reading prompt"
// Its own word, because the state it is easily mistaken for means the opposite: "idle"
// invites the reader to type something, and a waiting session is going to carry on without
// them. See `SessionStatus::Waiting`.
"waiting" -> "waiting"
"awaitingInput" -> "your turn"
// A subagent's process was always its parent's, so it had none of its own to merely stop.
"exited" -> if (subagent) "finished" else "exited"
// Said in words, because it differs in kind from the others rather than in degree: the
// session is not idle and has not exited, nobody has been able to find out which. A muted
// colour alone would read as one of the quiet states.
"unknown" -> "can't tell"
// A state this build has never heard of, said as itself. The nearest word we do know would
// read as a fact somebody established.
else -> status
}
fun backgroundTaskLabel(count: Int): String = "$count bg ${if (count == 1) "task" else "tasks"}"
/**
* The colour that goes with [sessionStatusWord]: the accent is spent on the states that are about
* to do something or want something, and every quiet one shares the muted colour.
*
* Stated beside whatever draws it rather than inherited -- a colour that carries meaning has to
* carry its own contrast, since the surface under it will not change to rescue it.
*/
@Composable
fun sessionStatusColour(status: String): Color =
when (status) {
"awaitingInput" -> awaitingColor
"running" -> runningColor
"compacting" -> commandColor
// The same accent as the other states that are busy on their own account, because that is
// what this is: something is happening and nothing is wanted from the reader.
"loading",
"reading" -> commandColor
"waiting" -> waitingColor
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
@@ -15,8 +15,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.Duration import java.time.Duration
@@ -72,22 +70,13 @@ class UsageFeed(
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */ /** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit, val refresh: () -> Unit,
) { ) {
/** /** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
* What meters [session], and what that meter came back as. See [usageFor] for the states. fun forSetup(setup: String): SessionUsage =
* when (val state = snapshots) {
* 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.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message) is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model) is LoadState.Loaded -> usageFor(state.value, setup)
} }
}
} }
/** /**
@@ -127,8 +116,8 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
* *
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a * Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked. * blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows this session's provider returned rather than the three Claude * Taken over however many windows came back rather than the three Claude sends today -- the backend
* sends today -- the backend passes windows it does not recognise straight through. * passes windows it does not recognise straight through.
* *
* Every state that is not a measurement takes the ordinary control colour instead. That is the * Every state that is not a measurement takes the ordinary control colour instead. That is the
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an * point where colour stops being able to help: blue is the low end of a scale here, so colouring an
@@ -144,7 +133,7 @@ fun usageGlyphColour(usage: SessionUsage): Color =
} }
/** /**
* The shortest usage window for the pool this session uses, under the session's own header. * The five-hour window for the machine this session runs on, under the session's own header.
* *
* Here rather than only in the usage dialog because it is the number that decides whether to keep * Here rather than only in the usage dialog because it is the number that decides whether to keep
* going, and it was a screen away from the place that decision gets made. It reports on this * going, and it was a screen away from the place that decision gets made. It reports on this
@@ -161,17 +150,17 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal // rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved // value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
// would sit at a stale figure for hours. // would sit at a stale figure for hours.
val now = rememberUsageNow() var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(REFRESH_MS)
now = OffsetDateTime.now()
}
}
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report // Nothing at all for a machine that meters nothing: a row saying "unknown" there would report a
// a problem about a machine somebody chose, on every screen, forever. // problem about a setup somebody chose, on every screen, forever.
// if (usage is SessionUsage.NotMetered) {
// 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 return
} }
@@ -182,18 +171,24 @@ 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 // 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. // answer from "this much is used", and only words carry a difference in kind.
when (val state = usage) { when (val state = usage) {
// Both handled above, before the row exists at all. SessionUsage.NotMetered -> Unit
SessionUsage.NotMetered, is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
SessionUsage.Waiting -> Unit SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
is SessionUsage.Unavailable -> UsageNote("Usage unknown -- ${state.why}")
is SessionUsage.Known -> { is SessionUsage.Known -> {
val window = shortestUsageWindow(state.windows) val window = state.windows.firstOrNull { it.kind == "session" }
if (window == null) { if (window == null) {
UsageNote("Usage unknown -- no window duration was reported") UsageNote("5-hour usage unknown -- no five-hour window reported")
} else { } else {
UsageProgressIndicator(window, now, Modifier.weight(1f)) LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
// The same step at the same percentages as the dialog's bars: this is the
// same measurement, and a reader who learned the colour there has to be
// able to read it here without checking which screen they are on.
color = quotaColor(window.percent),
modifier = Modifier.weight(1f),
)
Text( Text(
usageWindowLabel(window, now), fiveHourLabel(window, now),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
@@ -204,56 +199,6 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
} }
} }
/** A clock shared by each usage surface, advanced independently of changes to the quota. */
@Composable
internal fun rememberUsageNow(): OffsetDateTime {
var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(REFRESH_MS)
now = OffsetDateTime.now()
}
}
return now
}
/** The quota fill with a white tick showing how far the current time window has progressed. */
@Composable
internal fun UsageProgressIndicator(
window: UsageWindow,
now: OffsetDateTime,
modifier: Modifier = Modifier,
) {
val elapsed = usageWindowElapsedFraction(window, now)
LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
// The same step at the same percentages everywhere: this is the same measurement, and a
// reader who learned the colour on one surface should not have to relearn it on another.
color = quotaColor(window.percent),
modifier =
modifier.drawWithContent {
drawContent()
elapsed?.let { fraction ->
drawLine(
color = Color.White,
start = Offset(size.width * fraction, 0f),
end = Offset(size.width * fraction, size.height),
strokeWidth = 2.dp.toPx(),
)
}
},
)
}
/** Elapsed time divided by the reported window duration, or null when either value is unknown. */
internal fun usageWindowElapsedFraction(window: UsageWindow, now: OffsetDateTime): Float? {
val durationMinutes = window.durationMinutes?.takeIf { it > 0 } ?: return null
val end = windowEnd(window.resetsAt, now) as? WindowEnd.Ends ?: return null
val remainingMinutes =
end.until.seconds.toDouble() / 60.0 + end.until.nano.toDouble() / 60_000_000_000.0
return (1.0 - remainingMinutes / durationMinutes).coerceIn(0.0, 1.0).toFloat()
}
/** Anything this row says instead of drawing a bar, so all of them look the same. */ /** Anything this row says instead of drawing a bar, so all of them look the same. */
@Composable @Composable
private fun UsageNote(text: String) { private fun UsageNote(text: String) {
@@ -265,103 +210,42 @@ private fun UsageNote(text: String) {
} }
/** /**
* "42% -- 2h 15m left / 5h": how much is gone, then how long what is left has to last, then how * "42% -- 2h 15m left": how much is gone, then how long what is left has to last.
* long the whole window is.
* *
* The percentage on its own does not answer the question it gets asked, which is whether to start * The percentage on its own does not answer the question it gets asked, which is whether to start
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers. * something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
* *
* The window's *length* is what the provider's own name for it used to carry ("5-hour window"), and
* it is worth more beside the time left than in front of the percentage: "3h 42m left / 5h" says in
* one reading both how much of the cycle is to come and which cycle this is. Where the provider
* reported no duration there is simply nothing after the span -- the name it gave is not a
* measurement of one, so nothing is inferred from it.
*
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window * The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
* that is not running gets the percentage and nothing else. * that is not running gets the percentage and nothing else.
*/ */
private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String { private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%" val percent = "${window.percent.toInt()}%"
val outOf =
window.durationMinutes?.takeIf { it > 0 }?.let { " / ${formatMillis(it * 60_000)}" } ?: ""
return when (val end = windowEnd(window.resetsAt, now)) { return when (val end = windowEnd(window.resetsAt, now)) {
// Between blocks a window can have no reset time, and saying so is a fact about nothing: // Between blocks the five-hour window has no reset time, and saying so is a fact about
// there is no window to run out. The percentage is the whole answer. // nothing: there is no window to run out. The percentage is the whole answer.
WindowEnd.NotRunning -> percent WindowEnd.NotRunning -> percent
WindowEnd.Unreadable -> "$percent · reset time unreadable" WindowEnd.Unreadable -> "$percent · reset time unreadable"
is WindowEnd.Ends -> is WindowEnd.Ends ->
// Under a minute, including past the end: the number would round to "0m left", which // Under a minute, including past the end: the number would round to "0m left", which
// reads as a measurement rather than as the window having run out. // reads as a measurement rather than as the window having run out.
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon" if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
else "$percent · ${formatSpan(end.until)} left$outOf" else "$percent · ${formatSpan(end.until)} left"
} }
} }
/** /**
* One meter's snapshot, out of every machine's: [machine]'s row for [provider]. * One machine's snapshot, out of every machine's.
*
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
* service on one machine.
* *
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it. * 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 * None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered. * machine having no quota rather than the question going unanswered.
*/ */
fun usageFor( fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
snapshots: List<UsageSnapshot>, // No snapshot at all means the backend never asked, which it only does for a machine with
machine: String, // nothing metered on it. That is a different answer from having asked and failed.
provider: String, val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
model: 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 pools = usageSnapshotsFor(snapshots, machine, provider)
if (pools.isEmpty()) return SessionUsage.NotMetered
val mine =
usagePoolFor(pools, model)
?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses")
if (mine.state != "ok") { if (mine.state != "ok") {
val why = return SessionUsage.Unavailable(mine.detail ?: mine.state)
mine.detail
?: when (mine.state) {
"notLoggedIn" -> "no Claude account is signed in on this machine"
"authenticating" -> "Claude sign-in is in progress"
"loginRequired" -> "Claude sign-in is required"
else -> mine.state
}
return SessionUsage.Unavailable(why)
} }
return SessionUsage.Known(mine.windows) return SessionUsage.Known(mine.windows)
} }
/** Every billing pool reported for one provider on one machine. */
internal fun usageSnapshotsFor(
snapshots: List<UsageSnapshot>,
machine: String,
provider: String?,
): List<UsageSnapshot> =
if (provider == null) emptyList()
else snapshots.filter { it.machine == machine && it.provider == provider }
/** The pool an explicit model names, or the provider's generic pool for every other model. */
internal fun usagePoolFor(pools: List<UsageSnapshot>, model: String?): UsageSnapshot? {
if (pools.size == 1) return pools.first()
val normalizedModel = model?.normalizedPoolName()
val named = normalizedModel?.let { wanted ->
pools.firstOrNull { pool ->
val name = pool.limitName?.normalizedPoolName()
name == wanted || (wanted.contains("luna") && name == "gptreserve")
}
}
return named ?: pools.firstOrNull { it.limitId == "codex" }
}
/** The shortest cycle the selected pool actually reported. */
internal fun shortestUsageWindow(windows: List<UsageWindow>): UsageWindow? =
windows
.mapNotNull { window -> window.durationMinutes?.let { duration -> duration to window } }
.minByOrNull { it.first }
?.second
private fun String.normalizedPoolName(): String = lowercase().filter(Char::isLetterOrDigit)
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -124,14 +125,28 @@ fun SettingsScreen(
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
LabelledField(label = "Host", value = host, onValueChange = { host = it }) OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
LabelledField(label = "Port", value = port, onValueChange = { port = it }) OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
LabelledField( OutlinedTextField(
label = if (existing != null) "Token (unchanged if left blank)" else "Token",
value = token, value = token,
onValueChange = { token = it }, onValueChange = { token = it },
label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
@@ -1,7 +1,5 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -12,9 +10,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -26,11 +24,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -44,25 +37,19 @@ import kotlinx.coroutines.withContext
* which is what keeps the enrolled token from being able to introduce commands. * which is what keeps the enrolled token from being able to introduce commands.
*/ */
@Composable @Composable
fun MachinesScreen( fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
settings: ServerSettings,
reloadToken: Int,
/** Opens one provider on one machine -- its settings, and what its server is holding. */
onProvider: (String, String) -> Unit,
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) } var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var adding by remember { mutableStateOf(false) } var adding by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<Machine?>(null) } var renaming by remember { mutableStateOf<Setup?>(null) }
var confirmingDelete by remember { mutableStateOf<Machine?>(null) } var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
var signingIn by remember { mutableStateOf<Pair<Machine, Provider>?>(null) }
var busy by remember { mutableStateOf<String?>(null) } var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) } var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() { suspend fun reload() {
state = state =
try { try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) } withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
@@ -95,19 +82,19 @@ fun MachinesScreen(
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> is LoadState.Loaded ->
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
uniqueItems(current.value, key = { it.id }) { machine -> uniqueItems(current.value, key = { it.id }) { setup ->
MachineCard( SetupCard(
machine = machine, setup = setup,
onRename = { renaming = machine }, onRename = { renaming = setup },
onRediscover = { onRediscover = {
scope.launch { scope.launch {
busy = "Asking ${machine.name} what it has…" busy = "Asking ${setup.name} what it has…"
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
updateMachine( updateSetup(
settings, settings,
machine.id, setup.id,
rediscover = true, rediscover = true,
) )
} }
@@ -118,9 +105,7 @@ fun MachinesScreen(
reload() reload()
} }
}, },
onDelete = { confirmingDelete = machine }, onDelete = { confirmingDelete = setup },
onSignIn = { provider -> signingIn = machine to provider },
onProvider = { provider -> onProvider(machine.id, provider.name) },
) )
} }
} }
@@ -128,7 +113,7 @@ fun MachinesScreen(
} }
if (adding) { if (adding) {
AddMachineDialog( AddSetupDialog(
onDismiss = { adding = false }, onDismiss = { adding = false },
onAdd = { name, ssh -> onAdd = { name, ssh ->
adding = false adding = false
@@ -136,7 +121,7 @@ fun MachinesScreen(
busy = "Asking $name what it has…" busy = "Asking $name what it has…"
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { addMachine(settings, name, ssh) } withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
} }
.exceptionOrNull() .exceptionOrNull()
?.message ?.message
@@ -144,13 +129,13 @@ fun MachinesScreen(
reload() reload()
} }
}, },
onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } }, onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
) )
} }
renaming?.let { machine -> renaming?.let { setup ->
RenameDialog( RenameDialog(
machine = machine, setup = setup,
onDismiss = { renaming = null }, onDismiss = { renaming = null },
onRename = { name -> onRename = { name ->
renaming = null renaming = null
@@ -158,7 +143,7 @@ fun MachinesScreen(
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
updateMachine(settings, machine.id, name = name) updateSetup(settings, setup.id, name = name)
} }
} }
.exceptionOrNull() .exceptionOrNull()
@@ -169,10 +154,10 @@ fun MachinesScreen(
) )
} }
confirmingDelete?.let { machine -> confirmingDelete?.let { setup ->
AlertDialog( AlertDialog(
onDismissRequest = { confirmingDelete = null }, onDismissRequest = { confirmingDelete = null },
title = { Text("Remove \"${machine.name}\"?") }, title = { Text("Remove \"${setup.name}\"?") },
text = { text = {
Text( Text(
"The machine is left alone -- this only stops this app offering it. " + "The machine is left alone -- this only stops this app offering it. " +
@@ -186,9 +171,7 @@ fun MachinesScreen(
scope.launch { scope.launch {
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
deleteMachine(settings, machine.id)
}
} }
.exceptionOrNull() .exceptionOrNull()
?.message ?.message
@@ -204,99 +187,34 @@ fun MachinesScreen(
}, },
) )
} }
signingIn?.let { (machine, provider) ->
ProviderLoginDialog(
settings = settings,
machineId = machine.id,
machineName = machine.name,
provider = provider.name,
onDismiss = { signingIn = null },
onSignedIn = {
signingIn = null
scope.launch { reload() }
},
)
}
} }
@Composable @Composable
private fun MachineCard( private fun SetupCard(
machine: Machine, setup: Setup,
onRename: () -> Unit, onRename: () -> Unit,
onRediscover: () -> Unit, onRediscover: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onSignIn: (Provider) -> Unit,
onProvider: (Provider) -> Unit,
) { ) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(machine.name, style = MaterialTheme.typography.titleSmall) Text(setup.name, style = MaterialTheme.typography.titleSmall)
Text( Text(
// Not "this machine": the seeded machine is *called* that, and the card read "this // Not "this machine": the seeded setup is *called* that, and the card read "this
// machine / this machine". // machine / this machine".
machine.address ?: "runs where the backend does", setup.address ?: "runs where the backend does",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
if (machine.providers.isEmpty()) { Text(
Text( if (setup.providers.isEmpty()) {
"Nothing found on it. Install something and rediscover.", "Nothing found on it. Install something and rediscover."
style = MaterialTheme.typography.bodySmall, } else {
) setup.providers.joinToString(" · ") { it.name }
} else { },
machine.providers.forEach { provider -> style = MaterialTheme.typography.bodySmall,
// A card of its own rather than a line of text: a provider is where the )
// settings that belong to *this machine* live -- how each of its models is
// loaded, the models themselves, and the server holding them -- and those had
// nowhere to be until one llama-server came to serve every session on a
// machine. Sized by its own padding rather than by whatever control happened
// to be on its row, like the tool call cards it is built after.
Card(
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onProvider(provider) }
.semantics { contentDescription = "Open ${provider.name}" },
// A border, and the machine card's own surface kept underneath it.
// The tint that was here before is one step along the surface ladder
// from the card it sits in, and two adjacent surfaces render as one flat
// block: these read as lines of text in a box rather than as things to
// open. One cue, and a visible one.
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
Column(Modifier.weight(1f)) {
Text(provider.name, style = MaterialTheme.typography.titleSmall)
// What was actually found, which is the honest second line and
// the one thing here nobody can change. No arrow: a card that
// lifts off the one behind it already reads as something to open,
// and the chevron was the only thing making these look like rows
// of a list.
provider.command?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// A program is identified by its name, which is the tail
// of its path.
overflow = TextOverflow.StartEllipsis,
)
}
}
if (provider.kind == "claude_cli") {
TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") }
}
}
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onRename) { Text("Rename") } TextButton(onClick = onRename) { Text("Rename") }
TextButton(onClick = onRediscover) { Text("Rediscover") } TextButton(onClick = onRediscover) { Text("Rediscover") }
@@ -308,7 +226,7 @@ private fun MachineCard(
} }
@Composable @Composable
private fun AddMachineDialog( private fun AddSetupDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAdd: (String, SshDetails?) -> Unit, onAdd: (String, SshDetails?) -> Unit,
onTest: suspend (SshDetails?) -> List<Provider>, onTest: suspend (SshDetails?) -> List<Provider>,
@@ -318,7 +236,6 @@ private fun AddMachineDialog(
var address by remember { mutableStateOf("") } var address by remember { mutableStateOf("") }
var identity by remember { mutableStateOf("") } var identity by remember { mutableStateOf("") }
var attachmentsDir by remember { mutableStateOf("") } var attachmentsDir by remember { mutableStateOf("") }
var modelsDir by remember { mutableStateOf("") }
var tested by remember { mutableStateOf<String?>(null) } var tested by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) } var testing by remember { mutableStateOf(false) }
@@ -333,7 +250,6 @@ private fun AddMachineDialog(
port = typedPort, port = typedPort,
identityFile = identity.trim().ifEmpty { null }, identityFile = identity.trim().ifEmpty { null },
attachmentsDir = attachmentsDir.trim().ifEmpty { null }, attachmentsDir = attachmentsDir.trim().ifEmpty { null },
modelsDir = modelsDir.trim().ifEmpty { null },
) )
} }
@@ -349,36 +265,33 @@ private fun AddMachineDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
LabelledField(label = "Name", value = name, onValueChange = { name = it }) OutlinedTextField(
Spacer(Modifier.height(8.dp)) value = name,
LabelledField( onValueChange = { name = it },
// Just the shape. What a blank one means is said once, in the text above this label = { Text("Name") },
// form -- repeating it here wrapped the label onto a second line. singleLine = true,
label = "user@host[:port]", )
OutlinedTextField(
value = address, value = address,
onValueChange = { address = it }, onValueChange = { address = it },
// Just the shape. What a blank one means is said once, in the text above this
// form -- repeating it here wrapped the label onto a second line.
label = { Text("user@host[:port]") },
singleLine = true,
) )
Spacer(Modifier.height(8.dp)) OutlinedTextField(
LabelledField(
label = "Key path on the backend",
value = identity, value = identity,
onValueChange = { identity = it }, onValueChange = { identity = it },
label = { Text("Key path on the backend") },
singleLine = true,
) )
Spacer(Modifier.height(8.dp)) // Where a file attached from the phone lands on that machine. Blank means the
LabelledField( // session's own directory, which is what most people want.
// Where a file attached from the phone lands on that machine. OutlinedTextField(
label = "Folder for attached files",
value = attachmentsDir, value = attachmentsDir,
onValueChange = { attachmentsDir = it }, onValueChange = { attachmentsDir = it },
hint = "the session's own directory", label = { Text("Folder for attached files (optional)") },
) singleLine = true,
Spacer(Modifier.height(8.dp))
LabelledField(
// Where that machine's GGUFs are, for a llama.cpp session on it.
label = "Folder for models",
value = modelsDir,
onValueChange = { modelsDir = it },
hint = "the same place this backend keeps its own downloads",
) )
tested?.let { tested?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -426,14 +339,19 @@ private fun AddMachineDialog(
} }
@Composable @Composable
private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) { private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(machine.name) } var name by remember { mutableStateOf(setup.name) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Rename") }, title = { Text("Rename") },
text = { text = {
Column { Column {
LabelledField(label = "Name", value = name, onValueChange = { name = it }) OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
"Sessions already running on it keep working -- they refer to the machine, " + "Sessions already running on it keep working -- they refer to the machine, " +
@@ -1,250 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.absoluteValue
import kotlinx.coroutines.launch
private const val OPEN_THRESHOLD = 0.35f
private val FLING_THRESHOLD = 400.dp
/**
* How much of the screen a panel takes by default, leaving a sliver of what it is over.
*
* A panel given the whole width instead is standing in for the screen rather than sitting over it,
* and then the sliver would be a strip of a screen the reader has just left behind.
*/
const val PANEL_FRACTION = 0.88f
/** How dark the scrim over [SidePanels]' content goes with a panel fully open. */
private const val SCRIM_ALPHA = 0.32f
/**
* Which side of the content a panel comes in from: where it sits, and which way it slides out.
*
* [sign] is also the direction of the reveal this side owns, so the drag arithmetic is written once
* rather than once per side with the minus signs moved around.
*/
enum class PanelSide(val alignment: Alignment, val sign: Float) {
Left(Alignment.CenterStart, -1f),
Right(Alignment.CenterEnd, 1f),
}
/**
* Keeps [content] composed while a panel belonging to it moves over from the left or the right.
*
* One gesture drives both sides rather than one handler each, because two `draggable`s over the
* same content cannot share a horizontal drag: the inner one claims it whichever way the finger
* went, and the outer never sees a thing. So the position is a single signed reveal -- negative is
* the left panel showing, positive the right -- which also makes it impossible to have both open.
*
* A side left null has no panel and no gesture toward it -- the reveal cannot travel that way at
* all -- so one composable serves a screen with one panel and a screen with two.
*
* The root drag handler deliberately sits behind descendants. A horizontal scroller consumes its
* drag first, so code blocks, attachments and tool inputs keep their existing gesture. Collapsing
* that content, or starting over any ordinary part of the session, gives the gesture back to the
* panel; Android's own right-edge Back gesture remains untouched.
*/
@Composable
fun SidePanels(
left: (@Composable (active: Boolean, close: () -> Unit) -> Unit)? = null,
leftFraction: Float = PANEL_FRACTION,
right: (@Composable (active: Boolean, close: () -> Unit) -> Unit)? = null,
rightFraction: Float = PANEL_FRACTION,
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
// Which panel the gesture settled on, null for neither. The *settled* side rather than the
// current position, so a panel's contents know they are being looked at while the animation
// is still running.
var opened by remember { mutableStateOf<PanelSide?>(null) }
var dragging by remember { mutableStateOf(false) }
var draggedReveal by remember { mutableFloatStateOf(0f) }
val animatedReveal = remember { Animatable(0f) }
// Read from a draw or layout lambda, never from the composable body: where the panel has got
// to changes every frame of a drag, and a body that reads it recomposes this whole subtree --
// the session included -- once per frame. The booleans below are what composition is allowed
// to know, and each of them changes twice per gesture. (Same rule as the keyboard inset in
// SessionScreen, and found the same way.)
fun revealNow() = if (dragging) draggedReveal else animatedReveal.value
val leftShown by remember { derivedStateOf { revealNow() < 0f } }
val rightShown by remember { derivedStateOf { revealNow() > 0f } }
val engaged = leftShown || rightShown
val flingThreshold = with(LocalDensity.current) { FLING_THRESHOLD.toPx() }
suspend fun startDrag() {
animatedReveal.stop()
draggedReveal = animatedReveal.value
dragging = true
}
// Which panel the reveal belongs to, [bias] breaking the tie at rest -- a drag away from
// nothing is toward whichever panel that direction opens.
fun sideOf(bias: Float): PanelSide? =
when {
draggedReveal < 0f -> PanelSide.Left
draggedReveal > 0f -> PanelSide.Right
bias > 0f -> PanelSide.Left
bias < 0f -> PanelSide.Right
else -> null
}
suspend fun finishDrag(velocity: Float) {
val side = sideOf(0f)
// How fast the finger is moving toward that side's open position: the left panel opens
// rightwards and the right panel leftwards, so the sign of a velocity only means something
// once it is read against the side. A fling decides on its own; anything slower is decided
// by how far in the panel already is.
val toward = side?.let { -it.sign * velocity } ?: 0f
val opens =
if (toward.absoluteValue > flingThreshold) toward > 0f
else draggedReveal.absoluteValue >= OPEN_THRESHOLD
val target = side.takeIf { opens }
opened = target
animatedReveal.snapTo(draggedReveal)
dragging = false
animatedReveal.animateTo(target?.sign ?: 0f)
}
fun close() {
opened = null
scope.launch { animatedReveal.animateTo(0f) }
}
BackHandler(enabled = opened != null) { close() }
BoxWithConstraints(Modifier.fillMaxSize()) {
val dragState = rememberDraggableState { delta ->
// Against the width of the panel this drag is moving, since the reveal is a fraction
// of it and the two sides need not be the same width.
val width =
sideOf(delta)?.let {
constraints.maxWidth * if (it == PanelSide.Left) leftFraction else rightFraction
} ?: return@rememberDraggableState
draggedReveal =
(draggedReveal - delta / width.coerceAtLeast(1f)).coerceIn(
if (left == null) 0f else -1f,
if (right == null) 0f else 1f,
)
}
val drag =
Modifier.draggable(
state = dragState,
orientation = Orientation.Horizontal,
onDragStarted = { startDrag() },
onDragStopped = { velocity -> finishDrag(velocity) },
)
Box(
Modifier.fillMaxSize()
.then(drag)
.then(if (engaged) Modifier.clearAndSetSemantics {} else Modifier)
) {
content()
}
if (engaged) {
Box(
Modifier.fillMaxSize()
.graphicsLayer { alpha = revealNow().absoluteValue * SCRIM_ALPHA }
.background(MaterialTheme.colorScheme.scrim)
.semantics { contentDescription = "Dismiss panel" }
.clickable { close() }
)
}
// Both panels stay composed while they are off screen, so opening one costs no
// composition -- but an off-screen panel is cleared from the semantics tree, since nothing
// a reader cannot see should be reachable by swiping through the screen.
left?.let { panel ->
SlidingPanel(
side = PanelSide.Left,
width = maxWidth * leftFraction,
raised = leftFraction < 1f,
shown = { (-revealNow()).coerceAtLeast(0f) },
visible = leftShown,
drag = drag,
) {
panel(opened == PanelSide.Left, ::close)
}
}
right?.let { panel ->
SlidingPanel(
side = PanelSide.Right,
width = maxWidth * rightFraction,
raised = rightFraction < 1f,
shown = { revealNow().coerceAtLeast(0f) },
visible = rightShown,
drag = drag,
) {
panel(opened == PanelSide.Right, ::close)
}
}
}
}
/**
* One panel at [shown] of the way in, sliding out to its own [side].
*
* [raised] is for a panel with some of the screen still beside it, which takes a tonal step to say
* it is above what it has not covered. A panel covering the whole width has nothing to be above,
* and a step there is a screen that is simply the wrong colour.
*
* [visible] says the same thing as `shown() > 0f` and is the form composition may read; see
* [SidePanels].
*/
@Composable
private fun BoxScope.SlidingPanel(
side: PanelSide,
width: Dp,
raised: Boolean,
shown: () -> Float,
visible: Boolean,
drag: Modifier,
contents: @Composable () -> Unit,
) {
Surface(
tonalElevation = if (raised) 3.dp else 0.dp,
shadowElevation = 8.dp,
modifier =
Modifier.align(side.alignment)
.width(width)
.fillMaxHeight()
.graphicsLayer { translationX = side.sign * size.width * (1f - shown()) }
.then(if (visible) Modifier else Modifier.clearAndSetSemantics {})
.then(drag),
) {
contents()
}
}
@@ -16,6 +16,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -47,52 +48,44 @@ fun SpawnScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// What the form is made of, and whether we have it yet. A failure here is not the same as a // What the form is made of, and whether we have it yet. A failure here is not the same as a
// server with nothing to offer, so it must not reach the pickers as empty lists. // server with nothing to offer, so it must not reach the pickers as empty lists.
var options by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) } var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
// Machine first, then one of its providers. Choosing a machine can invalidate the provider, so // Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the
// the // provider is stored by name and resolved against the current setup rather than held as an
// provider is stored by name and resolved against the current machine rather than held as an
// object that could outlive the list it came from. // object that could outlive the list it came from.
var machineName by remember { mutableStateOf<String?>(null) } var setupName by remember { mutableStateOf<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) } var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") } var model by remember { mutableStateOf("") }
var providerModels by remember { mutableStateOf<List<OfferedModel>>(emptyList()) }
var providerModelsLoading by remember { mutableStateOf(false) }
var providerModelsError by remember { mutableStateOf<String?>(null) }
var cwd by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") }
// Set only after the selected provider reports its own default. An empty value is not sent. // "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
var permissionMode by remember { mutableStateOf("") } // answering "allow Bash?" dozens of times per task is what this app exists to avoid.
// Null until the server has been asked, and null again if it answers "no level chosen" -- the var permissionMode by remember { mutableStateOf("auto") }
// 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) } var busy by remember { mutableStateOf(false) }
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// form worth keeping, and that one leaves nothing to fill in. // form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) } var spawnError by remember { mutableStateOf<String?>(null) }
// Whatever the chosen provider says it takes, by key. Empty until something is typed: an // Downloaded models, for a llama provider to choose between. Kept separate from the setups: a
// absent key means the server's own default, which is what every field's placeholder says. // Claude session needs none, so failing to list them must not stop the screen rendering.
var params by remember { mutableStateOf<Map<String, String>>(emptyMap()) } var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") }
var temperature by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Separate from the machines fetch below and deliberately not fatal: failing to learn the
// 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 = options =
try { try {
val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) } val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
val first = fetched.firstOrNull() val first = fetched.firstOrNull()
machineName = first?.name setupName = first?.name
providerName = first?.providers?.firstOrNull()?.name providerName = first?.providers?.firstOrNull()?.name
LoadState.Loaded(fetched) LoadState.Loaded(fetched)
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
models =
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
.getOrDefault(emptyList())
} }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
@@ -109,7 +102,7 @@ fun SpawnScreen(
// Nothing below is fillable until the options are here, and a failure to fetch them leaves // Nothing below is fillable until the options are here, and a failure to fetch them leaves
// no form worth showing -- so this reports and stops, rather than offering empty pickers // no form worth showing -- so this reports and stops, rather than offering empty pickers
// under an error message. // under an error message.
val machines = val setups =
when (val state = options) { when (val state = options) {
is LoadState.Loading -> { is LoadState.Loading -> {
CircularProgressIndicator() CircularProgressIndicator()
@@ -121,88 +114,51 @@ fun SpawnScreen(
} }
is LoadState.Loaded -> state.value is LoadState.Loaded -> state.value
} }
val machine = machines.firstOrNull { it.name == machineName } val setup = setups.firstOrNull { it.name == setupName }
val current = machine?.providers?.firstOrNull { it.name == providerName } val current = setup?.providers?.firstOrNull { it.name == providerName }
// Coding CLIs take a working directory, model, permission mode and thinking level. Keying // Only the Claude CLI has models, a working directory and permission modes; keying the
// the extra fields on the kind rather than the provider name keeps a second installation // extra fields on the kind rather than the provider name keeps a second Claude provider
// from needing anything here. // from needing anything here.
val isClaude = current?.kind == "claude_cli" val isClaude = current?.kind == "claude_cli"
val isCodex = current?.kind == "codex_cli"
val isCodingCli = isClaude || isCodex
val isLlama = current?.kind == "llama_cpp" val isLlama = current?.kind == "llama_cpp"
// Echo is the only kind with nothing to choose between.
val offersModels = isCodingCli || isLlama
// Where a session's tools act, which is the only thing a working directory decides.
val takesCwd = isCodingCli || isLlama
// Whichever machine and provider are chosen now, asked again when either changes. The
// previous answer is dropped first rather than left on screen: a model name from another
// machine looks exactly like one from this one.
LaunchedEffect(machine?.id, current?.name) {
model = ""
// A key from the previous provider would be a setting this one does not have, drawn
// by no control and sent at the spawn anyway.
params = emptyMap()
providerModels = emptyList()
providerModelsError = null
permissionMode = current?.defaultPermissionMode.orEmpty()
// Every kind that offers models at all, not only the coding CLIs: a llama provider
// answers with the GGUFs on the machine it runs on, through the same call. One
// question with one answer is what keeps the picker free of a branch on the kind.
if (machine == null || current == null || !offersModels) {
providerModelsLoading = false
return@LaunchedEffect
}
providerModelsLoading = true
try {
providerModels =
withContext(Dispatchers.IO) {
fetchProviderModels(settings, machine.id, current.name)
}
} catch (e: ApiException) {
providerModelsError = e.message
} finally {
providerModelsLoading = false
}
}
// The machine first, because it decides what can be run at all. // The machine first, because it decides what can be run at all.
ChipGroup( ChipGroup(
label = "Machine", label = "Setup",
options = machines.map { it.name }, options = setups.map { it.name },
selected = machineName, selected = setupName,
onSelect = { name -> onSelect = { name ->
machineName = name setupName = name
// The provider list changes with the machine, so a name carried over from the // The provider list changes with the machine, so a name carried over from the
// previous one would be a selection that isn't in the picker. Take that machine's // previous one would be a selection that isn't in the picker. Take that machine's
// first. // first.
providerName = providerName =
machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
}, },
) )
machine?.address?.let { setup?.address?.let {
Text( Text(
it, it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// The address belongs to the machine above it, not to the provider label below; without // The address belongs to the setup above it, not to the provider label below; without
// this they read as one block. // this they read as one block.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
// Only what this machine actually has. A machine with none says so rather than showing an // Only what this machine actually has. A setup with none says so rather than showing an
// empty row that reads as a failure. // empty row that reads as a failure.
if (machine != null && machine.providers.isEmpty()) { if (setup != null && setup.providers.isEmpty()) {
Text( Text(
"\"${machine.name}\" has no providers configured.", "\"${setup.name}\" has no providers configured.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
ChipGroup( ChipGroup(
label = "Provider", label = "Provider",
options = machine?.providers?.map { it.name }.orEmpty(), options = setup?.providers?.map { it.name }.orEmpty(),
selected = providerName, selected = providerName,
onSelect = { providerName = it }, onSelect = { providerName = it },
) )
@@ -210,115 +166,91 @@ fun SpawnScreen(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
LabelledField(label = "Title", value = title, onValueChange = { title = it }) OutlinedTextField(
value = title,
if (offersModels) { onValueChange = { title = it },
when { label = { Text("Title") },
providerModelsLoading -> singleLine = true,
Text( modifier = Modifier.fillMaxWidth(),
"Loading model choices…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
providerModelsError != null ->
Text(
"Model choices unavailable: $providerModelsError",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
// A llama session cannot start without one, so this says what to do about it
// rather than only that there is nothing -- the models it needs are on the
// machine that will serve them, which is not always this backend.
providerModels.isEmpty() && isLlama ->
Text(
"No models on ${machine.name}. The Models screen downloads " +
"to the backend; another machine needs the file put there itself.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
providerModels.isEmpty() ->
Text(
"This machine reported no selectable models.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> {
Spacer(Modifier.height(16.dp))
ChipGroup(
label = "Model",
// The label, and the id is what is sent: for a llama model those differ,
// since it is chosen by path and named by what is inside the file.
options = providerModels.map { it.label },
selected = providerModels.firstOrNull { it.id == model }?.label,
onSelect = { chosen ->
val id = providerModels.first { it.label == chosen }.id
// A llama session has to have one, so choosing the same chip twice
// must not clear it -- there is nothing to fall back to.
model = if (model == id && !isLlama) "" else id
},
)
}
}
Spacer(Modifier.height(16.dp))
}
if (isCodingCli) {
// Free text as well as the chips above: the catalog is a shortcut, and a CLI will
// take a name it did not list.
LabelledField(
label = "Model",
value = model,
onValueChange = { model = it },
hint = "the CLI's default",
)
Spacer(Modifier.height(16.dp))
}
// Nothing is running yet, so nothing here waits for a restart -- every one of these is
// read by the process this form is about to start.
ProviderParamFields(
specs = current?.params.orEmpty(),
values = params,
onChange = { params = it },
warnAboutRestart = false,
) )
// Every session whose tools act on files needs one, which is both kinds that have if (isLlama) {
// tools -- a llama session's built-in tools run in it exactly as a CLI's do. // A llama session names one of the models this backend has downloaded, so the choice is
if (takesCwd) { // that list rather than free text -- a name that is not on disk is a session that
LabelledField( // cannot start.
label = "Working directory", if (models.isEmpty()) {
Text(
"No models downloaded yet. Get one from the Models screen first.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Model",
// The file, not the whole key: the repository is the same for every
// quantisation of a model, so the file name is what tells two of them apart.
options = models.map { it.file },
selected = models.firstOrNull { it.key == modelKey }?.file,
onSelect = { file -> modelKey = models.first { it.file == file }.key },
)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = contextSize,
onValueChange = { contextSize = it },
label = { Text("Context size (blank = the model's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = temperature,
onValueChange = { temperature = it },
label = { Text("Temperature (blank = llama.cpp's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
}
if (isClaude) {
if (current.models.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
ChipGroup(
label = "Model",
options = current.models,
selected = model.ifEmpty { null },
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
)
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = model,
onValueChange = { model = it },
label = { Text("Model (blank = the CLI's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = cwd, value = cwd,
onValueChange = { cwd = it }, onValueChange = { cwd = it },
hint = "wherever the session's process starts", label = { Text("Working directory") },
placeholder = { Text("/home/…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
}
// Offered wherever the provider has modes, rather than where this screen believes it
// does: the server is what knows, and llama.cpp grew them without this line changing.
if (current != null && current.permissionModes.isNotEmpty()) {
ChipGroup( ChipGroup(
label = "Permissions", label = "Permissions",
options = current.permissionModes, options = PERMISSION_MODES,
selected = permissionMode, selected = permissionMode,
onSelect = { permissionMode = it }, onSelect = { permissionMode = it },
) )
Spacer(Modifier.height(16.dp))
}
if (isCodingCli) {
// 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)) Spacer(Modifier.height(24.dp))
@@ -336,29 +268,33 @@ fun SpawnScreen(
try { try {
val spawned = val spawned =
withContext(Dispatchers.IO) { 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 (isCodingCli) {
runCatching { setDefaultEffort(settings, effort) }
}
spawnSession( spawnSession(
settings, settings,
// The id, not the label: labels are editable and the server // The id, not the label: labels are editable and the server
// resolves by id. Non-null here, since `chosen` came from // resolves by id. Non-null here, since `chosen` came from
// `machine`'s own provider list. // `setup`'s own provider list.
machine = machine.id, setup = setup.id,
provider = chosen.name, provider = chosen.name,
title = title.trim(), title = title.trim(),
model = model.trim().takeIf { offersModels }, model =
cwd = cwd.trim().takeIf { takesCwd }, if (isLlama) modelKey else model.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { it.isNotEmpty() }, cwd = cwd.trim().takeIf { isClaude },
effort = effort.takeIf { isCodingCli }, permissionMode = permissionMode.takeIf { isClaude },
// Already only the keys somebody set: a field left blank // Sent only when set, so blank means "whatever llama.cpp does
// removes its key rather than sending an empty value, so // by default" rather than a zero.
// "blank" reaches the server as "your default". params =
params = params, buildMap {
if (isLlama) {
contextSize
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("contextSize", it) }
temperature
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("temperature", it) }
}
},
) )
} }
onSpawned(spawned) onSpawned(spawned)
@@ -368,8 +304,7 @@ fun SpawnScreen(
} }
} }
}, },
// A llama session names the file to load, so there is nothing to spawn without one. enabled = !busy && current != null && !(isLlama && modelKey == null),
enabled = !busy && current != null && !(isLlama && model.isEmpty()),
) { ) {
Text(if (busy) "Spawning..." else "Spawn") Text(if (busy) "Spawning..." else "Spawn")
} }
@@ -1,335 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* What a session has running beside the turn you are reading: its background tasks, then its
* subagents, in the panel [SidePanels] slides over it from the right.
*
* [active] is whether the panel is being looked at: the lists are fetched then rather than on
* composition, since the panel is composed for every session whether or not anybody opens it.
*
* [onOpenCall] takes the reader to where a background task was started, in the transcript under
* this panel -- so the panel is closed with it, which is the caller's to do.
*
* [backgroundTasks] is the live count from the session's own event stream, and is what the
* background list is refetched against: a card for work that has since finished is a stale
* measurement drawn as a current one, which is the one thing a list of what is running now must not
* do.
*
* Both lists are items of one lazy column rather than two stacked scrollers, so expanding the
* background section pushes the subagents down without either being able to run off the panel.
*/
@Composable
fun SubagentPanel(
settings: ServerSettings,
summary: SessionSummary,
active: Boolean,
backgroundTasks: Int,
onOpenSubagent: (SubagentSummary) -> Unit,
onOpenCall: (CallSite) -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
var rows by
remember(summary.id) { mutableStateOf<LoadState<List<SubagentSummary>>>(LoadState.Loading) }
var selected by remember(summary.id) { mutableStateOf(setOf<String>()) }
var deleting by remember(summary.id) { mutableStateOf(setOf<String>()) }
var deleteError by remember(summary.id) { mutableStateOf<String?>(null) }
var confirming by remember(summary.id) { mutableStateOf<List<SubagentSummary>?>(null) }
var refreshToken by remember(summary.id) { mutableIntStateOf(0) }
var background by
remember(summary.id) {
mutableStateOf<LoadState<List<BackgroundTaskSummary>?>>(LoadState.Loading)
}
var backgroundExpanded by remember(summary.id) { mutableStateOf(false) }
LaunchedEffect(active, refreshToken) {
if (!active) return@LaunchedEffect
rows = LoadState.Loading
rows =
try {
val fetched = withContext(Dispatchers.IO) { fetchSubagents(settings, summary.id) }
selected = selected intersect fetched.mapTo(mutableSetOf()) { it.id }
LoadState.Loaded(fetched)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// No reset to Loading on a refetch: the spinner belongs to the first fetch, and one flashed
// over the list at every start and end would blink precisely when something happened.
LaunchedEffect(active, backgroundTasks, refreshToken) {
if (!active || backgroundTasks == 0) return@LaunchedEffect
background =
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchBackgroundTasks(settings, summary.id) }
)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder)
Column(Modifier.fillMaxSize()) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
) {
backgroundTaskSection(
count = backgroundTasks,
tasks = background,
expanded = backgroundExpanded,
onToggle = { backgroundExpanded = !backgroundExpanded },
onRetry = { refreshToken++ },
onOpenCall = onOpenCall,
)
item(key = "subagents-heading") { PanelSectionHeading("Subagents") }
when (val state = rows) {
is LoadState.Loading ->
item(key = "subagents-loading") {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
is LoadState.Error ->
item(key = "subagents-error") {
Column {
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = { refreshToken++ }) { Text("Try again") }
}
}
is LoadState.Loaded ->
if (ordered.isNullOrEmpty()) {
item(key = "subagents-empty") {
Text(
"No subagents in this session.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
uniqueItems(ordered, key = { it.id }) { subagent ->
SubagentCard(
subagent = subagent,
selected = subagent.id in selected,
selecting = selected.isNotEmpty(),
deleting = subagent.id in deleting,
onClick = { onOpenSubagent(subagent) },
onSelect = {
selected =
if (subagent.id in selected) selected - subagent.id
else selected + subagent.id
},
)
}
}
}
}
if (selected.isNotEmpty()) {
val picked = ordered.orEmpty().filter { it.id in selected }
SubagentSelectionBar(
picked = picked,
onDelete = { confirming = picked },
modifier = Modifier.padding(horizontal = 16.dp),
)
}
deleteError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
confirming?.let { picked ->
AlertDialog(
onDismissRequest = { confirming = null },
title = {
Text(
if (picked.size == 1) "Delete this subagent?"
else "Delete ${picked.size} subagents?"
)
},
text = {
Text(
(if (picked.size == 1) "\"${picked.first().title}\"\n\n" else "") +
"A subagent's transcript is the only record of what it did: the session " +
"that started it kept just the Task call. Nothing else has a copy, so " +
"this can't be undone. The session itself is untouched."
)
},
confirmButton = {
TextButton(
onClick = {
confirming = null
selected = emptySet()
val ids = picked.map { it.id }
val gone = ids.toSet()
deleting += gone
deleteError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSubagents(settings, summary.id, ids)
gone.forEach {
transcriptCache
.session(TranscriptAddress(summary.id, it))
.purge()
}
}
val loaded = rows
if (loaded is LoadState.Loaded) {
rows =
LoadState.Loaded(loaded.value.filterNot { it.id in gone })
}
} catch (e: ApiException) {
deleteError = e.message ?: "Delete failed"
} finally {
deleting -= gone
}
}
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
)
}
}
private fun subagentOrder(rows: List<SubagentSummary>): List<SubagentSummary> =
rows.sortedWith(
compareByDescending<SubagentSummary> { it.status == "running" }
.thenByDescending { it.lastActivity }
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SubagentCard(
subagent: SubagentSummary,
selected: Boolean,
selecting: Boolean,
deleting: Boolean,
onClick: () -> Unit,
onSelect: () -> Unit,
) {
BusyItem(label = if (deleting) "deleting" else null) {
OutlinedCard(
colors =
if (selected)
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
else CardDefaults.outlinedCardColors(),
modifier =
Modifier.fillMaxWidth()
.combinedClickable(
enabled = !deleting,
onClick = { if (selecting) onSelect() else onClick() },
onLongClick = onSelect,
),
) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun SubagentSelectionBar(
picked: List<SubagentSummary>,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
val running = picked.count { it.status == "running" }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().heightIn(min = 48.dp),
) {
Text(
if (running == 0) "${picked.size} selected" else "$running still running",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDelete, enabled = running == 0) {
Text(
"Delete",
color =
if (running == 0) MaterialTheme.colorScheme.error
else LocalContentColor.current,
)
}
}
}
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@@ -134,20 +134,6 @@ val clearedColor: Color
val awaitingColor: Color val awaitingColor: Color
@Composable get() = Mocha.Peach @Composable get() = Mocha.Peach
/**
* Waiting on itself: the session's turn is over, but a subagent or a backgrounded command it
* started is still going, and it will speak again with nobody having typed anything.
*
* Its own colour rather than [awaitingColor], which is the opposite state -- that one means the
* reader has something to do, and this one means they specifically do not. Not [runningColor]
* either: nothing is being written, and a green "running" on a session that will say nothing for
* ten minutes is the wrong promise. Blue for the same reason [commandColor] is blue -- not stuck,
* but not replying to you either -- and a different blue because that one is the session acting on
* itself rather than getting on with what was asked.
*/
val waitingColor: Color
@Composable get() = Mocha.Sky
/** Approaching a limit -- still fine, worth seeing. */ /** Approaching a limit -- still fine, worth seeing. */
val warningColor: Color val warningColor: Color
@Composable get() = Mocha.Yellow @Composable get() = Mocha.Yellow
@@ -213,8 +199,6 @@ val rawSurface: Color
*/ */
fun catppuccinSyntax(): SyntaxPalette = fun catppuccinSyntax(): SyntaxPalette =
SyntaxPalette( SyntaxPalette(
addition = Mocha.Green,
deletion = Mocha.Red,
keyword = Mocha.Mauve, keyword = Mocha.Mauve,
string = Mocha.Green, string = Mocha.Green,
literal = Mocha.Peach, literal = Mocha.Peach,
@@ -1,79 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* A model's working, shut until somebody asks for it.
*
* Shut by default, like a tool call and a memory note and for the same reason: it is not what the
* session said, and left open it puts the reasoning between the question and the answer -- which on
* a small model is most of the conversation.
*
* The heading is the whole of what the reader gets for free, so it carries the one thing worth
* knowing without opening anything: whether this is still going, and if not how long it took. A
* spinner while it runs, because that is the same fact a running command reports and it is drawn
* the same way here.
*/
@Composable
fun ThinkingCard(
item: TranscriptItem.ThinkingRow,
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(thinkingHeadline(item), style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.width(8.dp))
if (item.open) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
}
}
if (expanded) {
// Plain text rather than markdown: this is a model talking to itself, so its
// half-finished lists and stray backticks are not markup it meant to write, and
// rendering them as such makes the working look like an answer.
Text(
item.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
/**
* "Thinking", "Thought for 12.4s", or "Thought".
*
* The third is the one worth keeping: a block whose turn ended before the model said anything --
* interrupted, stopped, a process that exited -- was thought about for a length of time nobody
* measured. Naming a span there would be this screen inventing one, and the reader has no way to
* tell an invented one from the rest.
*/
fun thinkingHeadline(item: TranscriptItem.ThinkingRow): String =
when {
item.open -> "Thinking"
item.ms != null -> "Thought for ${formatMillis(item.ms)}"
else -> "Thought"
}
@@ -1,6 +1,9 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -48,31 +51,24 @@ data class ToolInput(
private val SUBJECTS: Map<String, Pair<String, Language?>> = private val SUBJECTS: Map<String, Pair<String, Language?>> =
mapOf( mapOf(
"Bash" to ("command" to Language.SHELL), "Bash" to ("command" to Language.SHELL),
"Shell" to ("command" to Language.SHELL),
"Patch" to ("diff" to Language.DIFF),
"Read" to ("file_path" to null), "Read" to ("file_path" to null),
"Write" to ("file_path" to null), "Write" to ("file_path" to null),
"Edit" to ("file_path" to null), "Edit" to ("file_path" to null),
"Glob" to ("pattern" to null), "Glob" to ("pattern" to null),
"Grep" to ("pattern" to null), "Grep" to ("pattern" to null),
"WebFetch" to ("url" to null), "WebFetch" to ("url" to null),
"WebSearch" to ("query" to null),
// Persisted transcripts keep the provider vocabulary they were written with.
"web_search" to ("query" to null),
) )
/** Fields that are the tool's own prose about itself rather than input to it. */ /** Fields that are the tool's own prose about itself rather than input to it. */
private val DESCRIPTIONS = listOf("description", "prompt") private val DESCRIPTIONS = listOf("description", "prompt")
fun parseToolInput(tool: String, input: String): ToolInput { fun parseToolInput(tool: String, input: String): ToolInput {
if (input.trim() == "null") return ToolInput(null, null, null, null, emptyList())
val json = val json =
try { try {
JSONObject(input) JSONObject(input)
} catch (_: org.json.JSONException) { } catch (_: org.json.JSONException) {
// Not an object: older transcripts and some tools send a bare string. It is still the // Not an object: older transcripts and some tools send a bare string. It is still the
// input, so it is still shown. JSON null is the one exception: it means the call had // input, so it is still shown.
// no input, and drawing the word makes an absent value look like an instruction.
return ToolInput( return ToolInput(
null, null,
null, null,
@@ -82,20 +78,15 @@ fun parseToolInput(tool: String, input: String): ToolInput {
) )
} }
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null) val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
val subject = val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() }
subjectKey
?.let { json.text(it) }
?.takeIf { it.isNotBlank() }
?.let { if (tool == "Bash") renderedBashScript(it) ?: it else it }
val description = DESCRIPTIONS.firstNotNullOfOrNull { val description = DESCRIPTIONS.firstNotNullOfOrNull {
json.text(it)?.takeIf { value -> value.isNotBlank() } json.optString(it).takeIf { v -> v.isNotBlank() }
} }
val timeout = json.text("timeout")?.takeIf { it.isNotBlank() }?.let { formatMillisText(it) } val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
val rest = val rest =
json json
.keys() .keys()
.asSequence() .asSequence()
.filterNot(json::isNull)
.filter { it != subjectKey || subject == null } .filter { it != subjectKey || subject == null }
.filter { it !in DESCRIPTIONS || description == null } .filter { it !in DESCRIPTIONS || description == null }
.filter { it != "timeout" || timeout == null } .filter { it != "timeout" || timeout == null }
@@ -105,31 +96,6 @@ fun parseToolInput(tool: String, input: String): ToolInput {
return ToolInput(subject, language, description, timeout, rest) return ToolInput(subject, language, description, timeout, rest)
} }
private fun JSONObject.text(key: String): String? =
if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
/**
* Removes Codex's rendered Bash argv from old transcript rows.
*
* New events arrive normalized by the server, but persisted transcripts keep the input originally
* written to them. Only the outer pair are presentation quoting: quotes inside the command belong
* to the command and must not be parsed as an early end delimiter.
*/
internal fun renderedBashScript(command: String): String? {
val prefix =
listOf("/usr/bin/bash -lc ", "/bin/bash -lc ", "bash -lc ").firstOrNull {
command.startsWith(it)
} ?: return null
val quoted = command.removePrefix(prefix)
return quoted
.takeIf {
it.length >= 2 &&
((it.startsWith('\'') && it.endsWith('\'')) ||
(it.startsWith('"') && it.endsWith('"')))
}
?.substring(1, quoted.lastIndex)
}
/** /**
* A tool call's input: its subject highlighted, then whatever else it carried. * A tool call's input: its subject highlighted, then whatever else it carried.
* *
@@ -147,8 +113,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
RawBlock(modifier) { RawBlock(modifier) {
parsed.subject?.let { subject -> parsed.subject?.let { subject ->
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the // Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely. The sideways scroll that makes that readable is the block's, // one being read closely.
// shared with the lines below -- see [RawBlock].
Text( Text(
// Not cached: a tool's subject is one command line, which lexes in microseconds -- // Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it. // the cache exists for a fence with two hundred lines in it.
@@ -156,6 +121,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = false, softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) )
} }
parsed.rest.forEach { parsed.rest.forEach {
@@ -164,7 +130,6 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
} }
@@ -27,9 +27,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -64,9 +61,7 @@ sealed class TranscriptRow {
* *
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number, * A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
* and it is the *same* value whether the run is drawn as one card or as a group. Which value * and it is the *same* value whether the run is drawn as one card or as a group. Which value
* that is belongs to the item ([TranscriptItem.key]) everywhere a row is one thing; where * that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
* [groupRuns] cuts a run into several rows it is the one deciding, and it says so by handing
* each piece its key.
*/ */
abstract val key: Any abstract val key: Any
@@ -80,15 +75,23 @@ sealed class TranscriptRow {
*/ */
abstract val startSeq: Long abstract val startSeq: Long
data class Single(val item: TranscriptItem, override val key: Any = item.key) : data class Single(val item: TranscriptItem) : TranscriptRow() {
TranscriptRow() { override val key: Any
get() = item.key
override val startSeq: Long override val startSeq: Long
get() = item.seq get() = item.seq
} }
/** Two or more calls with nothing between them; drawn as one collapsed card. */ /** Two or more calls with nothing between them; drawn as one collapsed card. */
data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) : data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
TranscriptRow() { /** The run's own name, which every call in it already carries. */
val id: String
get() = calls.first().runId
override val key: Any
get() = id
override val startSeq: Long override val startSeq: Long
get() = calls.first().seq get() = calls.first().seq
} }
@@ -99,75 +102,33 @@ sealed class TranscriptRow {
* *
* A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words, * A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words,
* and the run this exists for is the burst of five greps nobody wants to scroll past. * and the run this exists for is the burst of five greps nobody wants to scroll past.
*
* The last call is left alone too, and so is one still running wherever in its run it sits. What
* the session is doing, or did last, is the one thing worth seeing without opening anything, and a
* heading counting it hides it. What folds a call back into its run is therefore not finishing but
* being overtaken: anything arriving behind it, a reply included, makes it history.
*
* [heldOut] is the one thing being read can change, and only in that direction: a call standing on
* its own that somebody is reading is not overtaken while they read it. Opening a call *already*
* inside a group does not pull it out (2026-09-16, after it briefly did) -- it is visible where it
* is, and grouping is what gives a row its identity, so a rule that reads the open set both ways
* makes the reader's own tap rebuild the rows around it: three rows became one the moment a call
* was closed, and no anchor survives a row that no longer exists -- the list jumped by 450px and
* took the closed card with it. Which calls are held out is [SessionScreen]'s to say, since being
* inside a group once is what settles it.
*/ */
fun groupToolRuns( fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
items: List<TranscriptItem>, DebugStats.timed("grouped tool runs") { groupRuns(items) }
heldOut: Set<String> = emptySet(),
): List<TranscriptRow> = DebugStats.timed("grouped tool runs") { groupRuns(items, heldOut) }
private fun groupRuns(items: List<TranscriptItem>, heldOut: Set<String>): List<TranscriptRow> { private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
val rows = mutableListOf<TranscriptRow>() val rows = mutableListOf<TranscriptRow>()
var run = mutableListOf<TranscriptItem.ToolRun>() var run = mutableListOf<TranscriptItem.ToolRun>()
// A run can occupy more than one non-adjacent piece, so claimed keys span the whole transcript
// rather than resetting at each piece.
var runId: String? = null
val claimedKeys = mutableSetOf<String>()
fun flush() { fun flush() {
val first = run.firstOrNull() ?: return when (run.size) {
// The first piece keeps the run's name, which survives a page landing in front of it 0 -> {}
// ([adoptRun]). Later pieces qualify that name with their first call; the suffix is the 1 -> rows += TranscriptRow.Single(run.first())
// final guard because a duplicate LazyColumn key takes down the whole screen. else -> rows += TranscriptRow.Tools(run.toList())
var key = first.runId
if (!claimedKeys.add(key)) {
key = "${first.runId}/${first.id}"
var suffix = 2
while (!claimedKeys.add(key)) {
key = "${first.runId}/${first.id}/${suffix++}"
}
} }
rows +=
if (run.size == 1) TranscriptRow.Single(first, key)
else TranscriptRow.Tools(run.toList(), key)
run = mutableListOf() run = mutableListOf()
} }
items.forEachIndexed { index, item -> items.forEach { item ->
// Grouped by the run each call says it belongs to, not by adjacency worked out here. // Grouped by the run each call says it belongs to, not by adjacency worked out here.
// Adjacency is the same answer most of the time and a worse one at the edges: a call // Adjacency is the same answer most of the time and a worse one at the edges: a call
// arriving next to an existing run, or a page of history arriving in front of one, both // arriving next to an existing run, or a page of history arriving in front of one, both
// change which call is *first*. // change which call is *first*.
val call = item as? TranscriptItem.ToolRun if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
if (call == null || call.runId != runId) { run += item
} else {
flush() flush()
runId = call?.runId if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
}
when {
call == null -> rows += TranscriptRow.Single(item)
// Standing outside the run is the call's place in the list as it is now, not something
// recorded on the call: the same finished call is a row of its own while it is the last
// thing that happened, or open and never yet grouped, and part of its group once a
// reply lands behind it.
call.done && call.id !in heldOut && index != items.lastIndex -> run += call
else -> {
flush()
run += call
flush()
}
} }
} }
flush() flush()
@@ -199,15 +160,7 @@ fun ToolGroup(
*/ */
onToggle: () -> Unit, onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean, isToolExpanded: (String) -> Boolean,
/** onToolToggle: (String) -> Unit,
* Toggles one call, and says where in the group it was drawn: how far down the group's own top
* edge its card begins, and how tall that card is now.
*
* The screen anchors on *rows*, and a call is not one -- but what the reader is opening or
* shutting is the call, and keeping it under their finger needs its place inside the row. Only
* the group knows that, so only the group can say it. See `SessionScreen`'s `toggleAnchored`.
*/
onToolToggle: (id: String, top: Int, height: Int) -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit, image: @Composable (String) -> Unit,
) { ) {
@@ -222,10 +175,8 @@ fun ToolGroup(
} }
return return
} }
val placed = remember { Placed() }
Column( Column(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.onPlaced { placed.top = it.positionInRoot().y }
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow) .background(MaterialTheme.colorScheme.surfaceContainerLow)
) { ) {
@@ -245,19 +196,13 @@ fun ToolGroup(
verticalArrangement = Arrangement.spacedBy(GROUP_GAP), verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
) { ) {
group.calls.forEachIndexed { index, call -> group.calls.forEachIndexed { index, call ->
val card = remember(call.id) { Placed() }
ToolCard( ToolCard(
tool = call, tool = call,
expanded = isToolExpanded(call.id), expanded = isToolExpanded(call.id),
onToggle = { onToggle = { onToolToggle(call.id) },
onToolToggle(call.id, (card.top - placed.top).toInt(), card.height)
},
onAnswer = onAnswer, onAnswer = onAnswer,
image = image, image = image,
shape = connectedShape(index, group.calls.size), shape = connectedShape(index, group.calls.size),
modifier =
Modifier.onPlaced { card.top = it.positionInRoot().y }
.onSizeChanged { card.height = it.height },
) )
} }
} }
@@ -267,18 +212,6 @@ fun ToolGroup(
} }
} }
/**
* Where something was last placed, in the window's coordinates, and how tall it was.
*
* Deliberately not snapshot state: it is written from the layout phase, and a write there that
* composition reads would schedule another recomposition of every group on screen, every frame.
* Nothing reads it except the gesture that follows.
*/
private class Placed {
var top = 0f
var height = 0
}
/** /**
* The height of a group's heading, and so of the bar at its foot. * The height of a group's heading, and so of the bar at its foot.
* *
@@ -360,17 +293,14 @@ fun ToolCard(
image: @Composable (String) -> Unit = {}, image: @Composable (String) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */ /** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape, shape: Shape = CardDefaults.shape,
modifier: Modifier = Modifier,
) { ) {
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) } val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val name = toolDisplayName(tool.tool)
val output = toolDisplayOutput(tool.tool, tool.output)
val deciding = tool.asks.any { it.answers.isEmpty() } val deciding = tool.asks.any { it.answers.isEmpty() }
val open = expanded || deciding val open = expanded || deciding
Card(modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) { Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
Column(Modifier.padding(GROUP_INSET_LARGE)) { Column(Modifier.padding(GROUP_INSET_LARGE)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(name, style = MaterialTheme.typography.titleSmall) Text(tool.tool, style = MaterialTheme.typography.titleSmall)
if (open) { if (open) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
parsed.timeout?.let { parsed.timeout?.let {
@@ -425,26 +355,24 @@ fun ToolCard(
if (tool.tool != ASK_USER_QUESTION) { if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp)) ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
} }
if (output.isNotEmpty()) { if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall) Text("Output", style = MaterialTheme.typography.labelSmall)
// What the tool printed, on the surface everything verbatim gets and in the // What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is // face it was written for: this is column-aligned far more often than it is
// prose, and a proportional font silently destroys the alignment that carried // prose, and a proportional font silently destroys the alignment that carried
// the meaning. Unwrapped for the same reason, and scrolled sideways by the // the meaning.
// block around it -- see [RawBlock].
// //
// Its terminal styling applied and the rest of the escapes taken out: colour is // Its terminal styling applied and the rest of the escapes taken out: colour is
// often the whole of what a diff or a test run is saying. Remembered against // often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once. // the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() } val palette = remember { ansiPalette() }
val styled = remember(output, palette) { ansiStyled(output, palette) } val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
RawBlock(Modifier.padding(top = 2.dp)) { RawBlock(Modifier.padding(top = 2.dp)) {
Text( Text(
styled, styled,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = false,
) )
} }
} }
@@ -463,22 +391,6 @@ fun ToolCard(
} }
} }
private val collaborationToolNames =
mapOf(
"Task" to "Spawn agent",
"TaskOutput" to "Wait for agents",
"SendMessage" to "Message agent",
"CloseAgent" to "Close agent",
"InterruptAgent" to "Interrupt agent",
"ListAgents" to "List agents",
"ResumeAgent" to "Resume agent",
)
internal fun toolDisplayName(tool: String): String = collaborationToolNames[tool] ?: tool
internal fun toolDisplayOutput(tool: String, output: String): String =
if (tool in collaborationToolNames && output == "completed") "" else output
/** /**
* The permission ask on the call it is about. * The permission ask on the call it is about.
* *
@@ -1,27 +0,0 @@
package com.example.aiapp
/**
* Where one transcript lives: a session's own, or one of its subagents'.
*
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
*/
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
val urlPath: String
get() =
if (subagentId == null) "sessions/$sessionId"
else "sessions/$sessionId/subagents/$subagentId"
/**
* Where this transcript's cache lives on the phone, relative to the cache root.
*
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
* session's cache directory takes its subagents' with it -- the same one-way door the server's
* own storage describes.
*/
val cachePath: String
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
}
@@ -35,15 +35,8 @@ class TranscriptCache(
private val root: File, private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) }, private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) { ) {
/** /** The cache for one session, whether or not anything has been stored for it yet. */
* The cache for one transcript, whether or not anything has been stored for it yet. fun session(id: String): SessionCache = SessionCache(File(root, id), warn)
*
* 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 * Deletes every session directory not in [ids], called after a successful list fetch. The path
@@ -63,45 +63,6 @@ sealed class TranscriptItem {
* inside that reply would step the list under them. * inside that reply would step the list under them.
*/ */
val settled: Boolean = false, val settled: Boolean = false,
/** A final value that supersedes provisional deltas behind a page boundary. */
val replacesPrefix: Boolean = false,
/**
* When the reply was sent, in epoch seconds: the time on its newest delta, which is the
* moment it finished rather than the moment it started.
*
* The transcript's own timestamp rather than a clock read here, so every device draws the
* same time under the same reply and a replayed page agrees with the live stream.
*/
val ts: Double = 0.0,
/**
* How fast it was generated, where the provider measured it; null everywhere else.
*
* Folded on from the turn's usage event rather than carried by the text, because it is not
* known until the reply is over.
*/
val tokensPerSecond: Double? = null,
/** How long the provider spent reading the prompt, where it measured that. */
val prefillMs: Long? = null,
) : TranscriptItem()
/**
* The model's working before -- or between -- the things it said.
*
* Its own row rather than part of the reply, and deliberately not a [ToolRun]: a run of tool
* calls collapses into one card, and folding a model's reasoning into "Called 6 tools" would
* file it as one of them. Shut by default, like every other card that is not what was said.
*
* Three states, because two of them are not the same absence. [open] is a block still being
* thought, which is what the spinner is for. A closed one with an [ms] says how long it took; a
* closed one without is a block whose turn ended before anything said -- an interrupted reply,
* a session stopped mid-thought -- and it says so by not naming a duration rather than by
* naming a wrong one.
*/
data class ThinkingRow(
override val seq: Long,
val text: String,
val ms: Long? = null,
val open: Boolean = true,
) : TranscriptItem() ) : TranscriptItem()
data class ToolRun( data class ToolRun(
@@ -182,29 +143,6 @@ sealed class TranscriptItem {
get() = arrived get() = arrived
} }
/**
* Where one turn ended and the next began with nothing said in between.
*
* A rule and no words. Two replies meet like this whenever a turn starts without anybody typing
* -- a subagent reporting back, a session the CLI picked up by itself -- and drawn with only
* the ordinary gap between them they read as one answer with a paragraph break through the
* middle of it. What the reader needs is to see that these are two; what started the turn is
* somebody else's transcript's business, and a row per background task is a screenful of
* dividers about work nobody was asking after.
*
* Made by the fold rather than sent by the server, because it is not something that happened:
* it is the boundary between two things that did. See [foldEvent].
*/
data class TurnBreak(override val seq: Long) : TranscriptItem() {
/**
* Its own key, because it shares a [seq] with the reply it sits above -- that reply's first
* delta is the event this was made at, and a keyed list refuses two items with one key by
* taking the app down.
*/
override val key: Any
get() = "break$seq"
}
/** /**
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather * A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
* than only shown while it waits, because it explains what follows: a conversation that * than only shown while it waits, because it explains what follows: a conversation that
@@ -237,18 +175,6 @@ sealed class TranscriptItem {
val preTokens: Long?, val preTokens: Long?,
val postTokens: Long?, val postTokens: Long?,
) : TranscriptItem() ) : 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()
} }
/** /**
@@ -293,7 +219,7 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
* with the seam wherever the reader happened to have paged. * with the seam wherever the reader happened to have paged.
*/ */
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> { fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitThinking(healSplitMessage(earlier, later)) val (older, newer) = healSplitMessage(earlier, later)
val startedEarlier = val startedEarlier =
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id } older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
val endedLater = val endedLater =
@@ -323,10 +249,9 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
/** /**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate. * Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
* *
* [foldEvent] never leaves an *unfinished* assistant message with another behind it inside one * [foldEvent] never leaves two assistant messages next to each other inside one page, so two
* page, so an unsettled one at a join is always the far half of the reply the boundary cut, and * meeting at a join are always the two halves of one reply, and leaving them apart drew a single
* leaving the two apart drew a single answer as two with a paragraph break through the middle of a * answer as two with a paragraph break through the middle of a sentence.
* sentence. Two settled replies meeting there are two turns and stay two.
* *
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older * The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is * half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
@@ -341,36 +266,6 @@ private fun healSplitMessage(
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) { if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
return earlier to later return earlier to later
} }
// A settled reply is a whole turn, so the two are two answers that happen to meet at the
// boundary rather than one cut in half -- the same distinction the fold makes, and joining them
// here would put back exactly the run-together paragraph it stops.
// The rule between them is put in here too, since the fold that would have made it never saw
// these two side by side.
if (head.settled) return earlier to (listOf(TranscriptItem.TurnBreak(tail.seq)) + later)
if (tail.replacesPrefix) return earlier.dropLast(1) to later
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
}
/**
* Rejoins a thinking block the page boundary cut, the same way [healSplitMessage] rejoins a reply.
*
* A block streams a fragment at a time exactly as a reply does, so a boundary lands inside one as
* readily. The older half then holds an open block whose [SessionEvent.ThinkingDone] is on the
* newer page -- so it spun for the rest of the conversation, saying the machine was working on a
* thought it finished minutes ago, and the same working was drawn as two blocks.
*
* Only where the older half is still open: a closed one has its own ending and the two are two
* blocks that happen to meet here. The newer half keeps its identity, for the reason [adoptRun]
* gives -- it is the part already on screen.
*/
private fun healSplitThinking(
pages: Pair<List<TranscriptItem>, List<TranscriptItem>>
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
val (earlier, later) = pages
val head = earlier.lastOrNull()
val tail = later.firstOrNull()
if (head !is TranscriptItem.ThinkingRow || tail !is TranscriptItem.ThinkingRow) return pages
if (!head.open) return pages
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1)) return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
} }
@@ -457,75 +352,24 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// first of them: a row whose identity changed with every delta would be a new row on // first of them: a row whose identity changed with every delta would be a new row on
// every frame, and the list would jump for the whole of a streamed answer. // every frame, and the list would jump for the whole of a streamed answer.
val last = items.lastOrNull() val last = items.lastOrNull()
// Only into a reply that is still arriving. A settled one is a turn that ended, and if (last is TranscriptItem.AssistantMsg) {
// text after it belongs to the next turn -- a separate message, drawn as its own row. // A message growing again is not finished, whatever a status said in between.
// Growing it instead ran two answers together with not even a space between them, items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
// which is what happens whenever a turn starts with nothing recorded in front of it:
// a subagent reporting back, or a peer message the CLI only owns up to at the end.
if (last is TranscriptItem.AssistantMsg && !last.settled) {
items.dropLast(1) + last.copy(text = last.text + event.delta, ts = entry.ts)
} else { } else {
// A rule between the two, and only where they actually meet: anything that draws a items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
// row of its own -- a message, a command, a peer note -- is already the boundary.
val between =
if (last is TranscriptItem.AssistantMsg)
listOf(TranscriptItem.TurnBreak(entry.seq))
else emptyList()
items + between + TranscriptItem.AssistantMsg(entry.seq, event.delta, ts = entry.ts)
} }
} }
is SessionEvent.AssistantTextFinal -> {
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg && !last.settled) {
items.dropLast(1) +
last.copy(text = event.text, replacesPrefix = true, ts = entry.ts)
} else {
val between =
if (last is TranscriptItem.AssistantMsg)
listOf(TranscriptItem.TurnBreak(entry.seq))
else emptyList()
items +
between +
TranscriptItem.AssistantMsg(
entry.seq,
event.text,
replacesPrefix = true,
ts = entry.ts,
)
}
}
is SessionEvent.Thinking -> {
// Deltas grow the open block, keeping the seq of the first of them, for the same
// reason a reply's do: a row whose identity changed per delta is a new row per frame.
val last = items.lastOrNull()
if (last is TranscriptItem.ThinkingRow && last.open) {
items.dropLast(1) + last.copy(text = last.text + event.delta)
} else {
items + TranscriptItem.ThinkingRow(entry.seq, event.delta)
}
}
// The newest block still open, rather than whatever row happens to be last.
is SessionEvent.ThinkingDone ->
closeThinking(items) { it.copy(ms = event.ms, open = false) }
is SessionEvent.ToolStart -> is SessionEvent.ToolStart ->
// A call id names one call for its whole lifetime. Codex can repeat the start while items +
// recovering an in-flight item; appending that replay made two rows with one key, and TranscriptItem.ToolRun(
// Compose aborts the entire LazyColumn when it encounters them. Ignoring the replay entry.seq,
// also repairs transcripts which already contain it when they are folded on reopen. event.id,
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) { runIdFor(items, event.id, event.tool),
items event.tool,
} else { event.input,
items + "",
TranscriptItem.ToolRun( done = false,
entry.seq, )
event.id,
runIdFor(items, event.id, event.tool),
event.tool,
event.input,
"",
done = false,
)
}
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) } is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd -> is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A fold that only ever // Created when its start is not here, rather than dropped. A fold that only ever
@@ -600,13 +444,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// nothing it belongs above. // nothing it belongs above.
is SessionEvent.MessageDropped -> items is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items is SessionEvent.Settings -> items
// Neither carries a row: both are about what the session can do rather than about anything
// said in it, and the composer is where they are drawn.
is SessionEvent.Images -> items
is SessionEvent.BackgroundTasks -> items
is SessionEvent.Status -> settleReply(items, event.state) is SessionEvent.Status -> settleReply(items, event.state)
is SessionEvent.AuthenticationRequired ->
items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image -> is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of its own when there is // Under the call that produced it when there is one, and a row of its own when there is
@@ -620,32 +458,12 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
} else { } else {
items + TranscriptItem.ImageItem(entry.seq, event.ref) 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.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted -> is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens) items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]") is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// Said rather than skipped: a line the server could not read is a hole in the conversation, // Screen-level state, not transcript rows -- see SessionScreen.
// and one that draws nothing is a hole nothing on screen ever mentions. is SessionEvent.UsageDelta -> items
is SessionEvent.Unreadable ->
items + TranscriptItem.Note(entry.seq, "[unreadable: ${event.kind}]")
// No row: see [SessionEvent.RetiredTaskNote].
is SessionEvent.RetiredTaskNote -> items
// No row of its own -- the counts are screen-level state, see SessionScreen -- but the
// generation speed belongs under the reply it measured, and this is where that reply ends.
// Only onto the newest row, and only when that row is a reply: a turn whose usage arrives
// after a tool call has nothing here to put it on, which draws as a footer without it.
is SessionEvent.UsageDelta ->
when (val last = items.lastOrNull()) {
is TranscriptItem.AssistantMsg ->
items.dropLast(1) +
last.copy(
tokensPerSecond = event.tokensPerSecond,
prefillMs = event.prefillMs,
)
else -> items
}
is SessionEvent.ContextWindow -> items
} }
/** /**
@@ -656,22 +474,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
*/ */
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> { private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
if (sessionWorking(state)) return items if (sessionWorking(state)) return items
// A block the turn ended in the middle of is over, however it ended. Left open it spins for val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
// the rest of the conversation, which says the machine is working when nothing is. if (last.settled) return items
val ended = closeThinking(items) { it.copy(open = false) } return items.dropLast(1) + last.copy(settled = true)
val last = ended.lastOrNull() as? TranscriptItem.AssistantMsg ?: return ended
if (last.settled) return ended
return ended.dropLast(1) + last.copy(settled = true)
}
/** [change] applied to the newest thinking block still open, if there is one. */
private fun closeThinking(
items: List<TranscriptItem>,
change: (TranscriptItem.ThinkingRow) -> TranscriptItem.ThinkingRow,
): List<TranscriptItem> {
val at = items.indexOfLast { it is TranscriptItem.ThinkingRow && it.open }
if (at < 0) return items
return items.toMutableList().apply { this[at] = change(this[at] as TranscriptItem.ThinkingRow) }
} }
private fun updateTool( private fun updateTool(
@@ -1,7 +1,6 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -11,9 +10,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.text.selection.SelectionState import androidx.compose.foundation.text.selection.SelectionState
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -53,8 +49,6 @@ fun TranscriptList(
units: List<TranscriptUnit>, units: List<TranscriptUnit>,
state: LazyListState, state: LazyListState,
moreHistory: Boolean, moreHistory: Boolean,
historyError: String?,
onRetryHistory: () -> Unit,
selection: SelectionState, selection: SelectionState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
below: @Composable () -> Unit, below: @Composable () -> Unit,
@@ -100,29 +94,15 @@ fun TranscriptList(
DebugStats.count("unit composed") DebugStats.count("unit composed")
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) } Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
} }
// Standing in for everything not fetched yet. A failed fetch stays actionable here: // Standing in for everything not fetched yet. Only here while there is more -- its
// when the loaded transcript is too short to scroll, this boundary is the only place // appearance at the top edge is also roughly when the next page is asked for, so what
// the reader can be given another way to ask. // it reports is a fetch in flight rather than an end reached.
if (moreHistory) { if (moreHistory) {
item(key = "history", contentType = "history") { item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) { Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
if (historyError == null) { CircularProgressIndicator(
CircularProgressIndicator( Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
Modifier.align(Alignment.Center).size(HISTORY_SPINNER) )
)
} else {
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Couldn't load earlier messages. $historyError",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = onRetryHistory) { Text("Try again") }
}
}
} }
} }
} }
@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicReference
*/ */
class TranscriptSource( class TranscriptSource(
private val settings: ServerSettings, private val settings: ServerSettings,
private val address: TranscriptAddress, private val sessionId: String,
val cache: SessionCache, val cache: SessionCache,
) { ) {
private val stream = AtomicReference<EventStream?>(null) private val stream = AtomicReference<EventStream?>(null)
@@ -65,7 +65,7 @@ class TranscriptSource(
val tail = cache.tail() ?: return false val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at* // `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. // the cursor when the server still has one there.
val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1) val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1)
val matches = val matches =
answer.size == 1 && answer.size == 1 &&
try { try {
@@ -83,7 +83,7 @@ class TranscriptSource(
*/ */
suspend fun fetchOpening(): List<SeqEvent> { suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server") DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, address, limit = OPENING_WINDOW) val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) } page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush() cache.flush()
return page.map { it.second } return page.map { it.second }
@@ -108,7 +108,7 @@ class TranscriptSource(
val page = val page =
fetchTranscript( fetchTranscript(
settings, settings,
address, sessionId,
before = before, before = before,
limit = limit, limit = limit,
coalesce = coalesce, coalesce = coalesce,
@@ -131,7 +131,7 @@ class TranscriptSource(
* well lose. * well lose.
*/ */
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, address) val opened = EventStream(settings, sessionId)
stream.getAndSet(opened)?.close() stream.getAndSet(opened)?.close()
try { try {
opened.run(after, onOpen, onReset) { raw, entry -> opened.run(after, onOpen, onReset) { raw, entry ->
@@ -130,25 +130,6 @@ sealed class TranscriptUnit {
get() = "u$seq:$ordinal" get() = "u$seq:$ordinal"
} }
/**
* The line under a finished reply: when it was sent, and how fast it was generated.
*
* A unit of its own rather than something drawn inside the last block, because a settled reply
* *is* its blocks -- there is no row left to hang it on, and the last block is a piece of
* markdown that knows nothing about the message it came from.
*/
data class ReplyFoot(
override val seq: Long,
override val ordinal: Int,
val ts: Double,
val tokensPerSecond: Double?,
val prefillMs: Long?,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "f$seq"
}
/** One memory note of a settled reply; see [MemoryNote]. */ /** One memory note of a settled reply; see [MemoryNote]. */
data class Memory( data class Memory(
override val seq: Long, override val seq: Long,
@@ -256,19 +237,6 @@ fun transcriptUnits(
} }
} }
} }
// Unconditional, because being in this branch is what says the reply is over:
// [splitWanted] is settled-or-overtaken. The case to keep out is a message still
// arriving, whose "sent at" is not yet the one it ends up with, and that is drawn
// whole.
units +=
TranscriptUnit.ReplyFoot(
row.startSeq,
ordinal,
item.ts,
item.tokensPerSecond,
item.prefillMs,
gap(FOOT_SPACING),
)
} else { } else {
units += TranscriptUnit.Whole(row, rowGap) units += TranscriptUnit.Whole(row, rowGap)
} }
@@ -312,14 +280,6 @@ fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<Tra
* length has lines that wrap, so its bubble is at the full width already and the slices match it * length has lines that wrap, so its bubble is at the full width already and the slices match it
* exactly. Below it, one item of at most a few screens is nothing the list minds composing. * exactly. Below it, one item of at most a few screens is nothing the list minds composing.
*/ */
/**
* The room between a reply's last block and the line under it.
*
* Tighter than the gap between blocks: the footer belongs to the message above it, and at a block's
* spacing it reads as a row of its own floating between two replies.
*/
private val FOOT_SPACING: Dp = 2.dp
const val USER_SPLIT_CHARS = 4000 const val USER_SPLIT_CHARS = 4000
/** /**
@@ -415,7 +375,6 @@ private val TranscriptUnit?.kind: String
is TranscriptUnit.PeerBlock -> "peer block" is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice" is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note" is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.ReplyFoot -> "reply footer"
is TranscriptUnit.Whole -> is TranscriptUnit.Whole ->
when (val row = row) { when (val row = row) {
is TranscriptRow.Tools -> "tool group" is TranscriptRow.Tools -> "tool group"
@@ -9,15 +9,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -33,14 +30,7 @@ import java.time.OffsetDateTime
* own, so the only thing its Back could ever have meant was "put this away". * own, so the only thing its Back could ever have meant was "put this away".
*/ */
@Composable @Composable
fun UsageDialog( fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
settings: ServerSettings,
feed: UsageFeed,
session: SessionSummary,
onDismiss: () -> Unit,
) {
var signingIn by remember { mutableStateOf(false) }
val now = rememberUsageNow()
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
// between its title, content and buttons at sizes meant for a sentence of prose and a decision; // between its title, content and buttons at sizes meant for a sentence of prose and a decision;
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was // this is a dense read-out, and those gaps left a band of empty dialog above Close that was
@@ -55,6 +45,10 @@ fun UsageDialog(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
// Deliberately not subtitled with the provider this was opened from. These
// numbers belong to an account on a particular machine -- naming the session's
// provider here made an echo session's screen read "echo" above a line reading
// "claude". Each machine names itself and the service it came from.
Text( Text(
"Usage", "Usage",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
@@ -71,24 +65,10 @@ fun UsageDialog(
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Scrolls rather than being trimmed: a machine can report any number of windows and // Scrolls rather than being trimmed: a machine can report any number of windows and
// a provider can report several billing pools, and a dialog is the one place where // there can be any number of machines, and a dialog is the one place where running
// running out of room is silent. `fill = false` so a short read-out keeps a short // out of room is silent. `fill = false` so a short read-out keeps a short dialog.
// dialog.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
val state = UsageBody(feed.snapshots)
when (val snapshots = feed.snapshots) {
is LoadState.Loading -> LoadState.Loading
is LoadState.Error -> snapshots
is LoadState.Loaded ->
LoadState.Loaded(
usageSnapshotsFor(
snapshots.value,
session.machine,
session.usageProvider,
)
)
}
UsageBody(state, now, onSignIn = { signingIn = true })
} }
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
Text("Close") Text("Close")
@@ -96,46 +76,29 @@ fun UsageDialog(
} }
} }
} }
if (signingIn) {
ProviderLoginDialog(
settings = settings,
machineId = session.machine,
machineName = session.machineName,
provider = session.provider,
onDismiss = { signingIn = false },
onSignedIn = {
signingIn = false
feed.refresh()
},
)
}
} }
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */ /** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
@Composable @Composable
private fun UsageBody( private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
state: LoadState<List<UsageSnapshot>>,
now: OffsetDateTime,
onSignIn: () -> Unit,
) {
Column { Column {
when (val current = state) { when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> is LoadState.Loaded ->
if (current.value.isEmpty()) { if (current.value.isEmpty()) {
// Not an error and not a blank screen: this provider has no paid quota, so // Not an error and not a blank screen: no machine offers a paid service, so
// there is genuinely nothing to report and saying so is the answer. // there is genuinely nothing to report and saying so is the answer.
Text( Text(
"This session's provider has no usage limits.", "No machine here runs anything with usage limits.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
// No card around each pool. A card is a step up the surface ladder, and inside // No card around each machine. A card is a step up the surface ladder, and
// a dialog -- itself a raised surface -- the step barely renders while costing // inside a dialog -- itself a raised surface -- the step barely renders while
// 16dp on every side. What separates one pool from the next is the line naming // costing 16dp on every side. What separates one machine from the next is the
// it. // line naming it.
current.value.forEachIndexed { index, snapshot -> current.value.forEachIndexed { index, snapshot ->
if (index > 0) { if (index > 0) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
@@ -145,18 +108,18 @@ private fun UsageBody(
// read as a section of their own. Small and quiet, because the numbers // read as a section of their own. Small and quiet, because the numbers
// below are what somebody opened this to see. // below are what somebody opened this to see.
Text( Text(
usageSectionTitle(snapshot), "${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
SnapshotState(snapshot, onSignIn) SnapshotState(snapshot)
snapshot.windows.forEachIndexed { windowIndex, window -> snapshot.windows.forEachIndexed { windowIndex, window ->
// Between the bars, not after the last one: a trailing gap here is what // Between the bars, not after the last one: a trailing gap here is what
// put a band of empty dialog above the Close button. // put a band of empty dialog above the Close button.
if (windowIndex > 0) { if (windowIndex > 0) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
WindowBar(window, now) WindowBar(window)
} }
} }
} }
@@ -164,44 +127,20 @@ private fun UsageBody(
} }
} }
private fun usageSectionTitle(snapshot: UsageSnapshot): String {
val machine = snapshot.machineName.ifEmpty { snapshot.machine }
val provider = snapshot.provider
val pool =
if (provider == "codex" && snapshot.limitId != "codex") {
when (snapshot.limitName) {
"gpt-reserve" -> "Luna Reserve"
null -> snapshot.limitId
else -> snapshot.limitName
}
} else null
return listOfNotNull(machine, provider, pool).joinToString(" · ")
}
/** /**
* Anything other than numbers: why this machine has none. * Anything other than numbers: why this machine has none.
* *
* The distinction the old single message could not draw. A machine nobody has logged in on is * The distinction the old single message could not draw. A machine nobody has logged in on is
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be * working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
* the interface nagging about a decision already made. It still offers the direct sign-in action; * the interface nagging about a decision already made. Only the two faults are coloured as faults.
* unreachable and provider failures are the states coloured as faults.
*/ */
@Composable @Composable
private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) { private fun SnapshotState(snapshot: UsageSnapshot) {
when (snapshot.state) { when (snapshot.state) {
"ok" -> {} "ok" -> {}
"notLoggedIn", "notLoggedIn" ->
"loginRequired" -> {
Text( Text(
snapshot.detail ?: "No Claude account on this machine.", "No Claude account on this machine.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onSignIn) { Text("Sign in") }
}
"authenticating" ->
Text(
"Claude sign-in is in progress.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -223,7 +162,7 @@ private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
} }
@Composable @Composable
private fun WindowBar(window: UsageWindow, now: OffsetDateTime) { private fun WindowBar(window: UsageWindow) {
Column { Column {
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
Text( Text(
@@ -234,8 +173,12 @@ private fun WindowBar(window: UsageWindow, now: OffsetDateTime) {
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium) Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
UsageProgressIndicator(window, now, Modifier.fillMaxWidth()) LinearProgressIndicator(
resetLine(window, now)?.let { progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
color = quotaColor(window.percent),
modifier = Modifier.fillMaxWidth(),
)
resetLine(window)?.let {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
it, it,
@@ -254,8 +197,8 @@ private fun WindowBar(window: UsageWindow, now: OffsetDateTime) {
* failure appeared as an ISO string in a sentence written for a person. Both are named in * failure appeared as an ISO string in a sentence written for a person. Both are named in
* [WindowEnd], and the session bar words them the same way. * [WindowEnd], and the session bar words them the same way.
*/ */
private fun resetLine(window: UsageWindow, now: OffsetDateTime): String? = private fun resetLine(window: UsageWindow): String? =
when (val end = windowEnd(window.resetsAt, now)) { when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) {
WindowEnd.NotRunning -> null WindowEnd.NotRunning -> null
WindowEnd.Unreadable -> "reset time unreadable" WindowEnd.Unreadable -> "reset time unreadable"
is WindowEnd.Ends -> is WindowEnd.Ends ->
Binary file not shown.
@@ -1,28 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AuthenticationPromptTest {
@Test
fun an_authentication_failure_stays_actionable_through_its_terminal_status() {
val required =
authenticationPromptAfter(
false,
SessionEvent.AuthenticationRequired("sign in again"),
)
assertTrue(authenticationPromptAfter(required, SessionEvent.Status("idle")))
}
@Test
fun a_later_provider_response_makes_an_old_failure_stale() {
assertFalse(
authenticationPromptAfter(
true,
SessionEvent.AssistantText("Working again."),
)
)
}
}
@@ -1,33 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
class FilesNavigationTest {
@Test
fun `back walks through the common ancestor toward the project`() {
val project = "/home/bob/repos/project"
assertEquals("/", nextDirectoryToward("/etc", project))
assertEquals("/home", nextDirectoryToward("/", project))
assertEquals("/home/bob", nextDirectoryToward("/home", project))
assertEquals("/home/bob/repos", nextDirectoryToward("/home/bob", project))
assertEquals(project, nextDirectoryToward("/home/bob/repos", project))
assertEquals(null, nextDirectoryToward(project, project))
}
@Test
fun `back leaves a project descendant one directory at a time`() {
assertEquals(
"/home/bob/repos/project/src",
nextDirectoryToward("/home/bob/repos/project/src/main", "/home/bob/repos/project"),
)
}
@Test
fun `paths inside the machine home use tilde notation`() {
assertEquals("~", tildePath("/home/bob", "/home/bob"))
assertEquals("~/repos/project", tildePath("/home/bob/repos/project", "/home/bob/"))
assertEquals("/home/bobby/project", tildePath("/home/bobby/project", "/home/bob"))
assertEquals("/etc", tildePath("/etc", "/home/bob"))
}
}
@@ -338,21 +338,6 @@ class HighlighterTest {
assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text) assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text)
} }
@Test
fun `a diff colours changes and identifies its framing separately`() {
val code = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n context\n+new"
assertSpans(code, Language.DIFF, Kind.DELETION, "-old")
assertSpans(code, Language.DIFF, Kind.ADDITION, "+new")
assertSpans(
code,
Language.DIFF,
Kind.METADATA,
"--- a/file",
"+++ b/file",
"@@ -1 +1 @@",
)
}
@Test @Test
fun `every language the fence table knows has a scanner`() { fun `every language the fence table knows has a scanner`() {
Language.entries.forEach { spansOf("x", it) } Language.entries.forEach { spansOf("x", it) }
@@ -1,32 +0,0 @@
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))
}
}
@@ -1,31 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class MarkdownLinksTest {
@Test
fun `absolute file paths are opened on the session machine`() {
assertEquals(
"/home/bob/repos/ai app/Main.kt",
filePathOf("/home/bob/repos/ai%20app/Main.kt"),
)
assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt"))
assertEquals("/home/bob/Main.kt", filePathOf("file://localhost/home/bob/Main.kt"))
}
@Test
fun `editor coordinates select the file itself`() {
assertEquals("/home/bob/Main.kt", filePathOf("/home/bob/Main.kt:42"))
assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt:42:7#L42"))
}
@Test
fun `ordinary links keep their external meaning`() {
assertNull(filePathOf("https://example.com/source.kt"))
assertNull(filePathOf("docs/source.kt"))
assertNull(filePathOf("//example.com/source.kt"))
assertNull(filePathOf("file://example.com/source.kt"))
}
}
@@ -1,72 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PendingMessagesTest {
private fun local(text: String = "keep this") =
QueuedMessage("local-1", text, emptyList(), local = true)
@Test
fun a_server_queue_replaces_the_local_bridge_instead_of_duplicating_it() {
val queued =
reconcileQueuedMessage(
listOf(local()),
SessionEvent.MessageQueued("server-1", "keep this", emptyList()),
)
assertEquals(1, queued.size)
assertEquals("server-1", queued.single().id)
assertTrue(!queued.single().local)
}
@Test
fun an_immediately_received_message_removes_its_local_bridge() {
val queued =
reconcileUserMessage(
listOf(local()),
SessionEvent.UserMessage("keep this", id = null, attachments = emptyList()),
)
assertTrue(queued.isEmpty())
}
@Test
fun a_transport_failure_stays_on_its_message() {
val queued = markPendingFailure(listOf(local()), "local-1", "Can't reach the server")
assertEquals("Can't reach the server", queued.single().refusal)
assertTrue(queued.single().local)
}
@Test
fun server_acceptance_keeps_the_bubble_until_the_provider_event() {
val queued = markPendingAccepted(listOf(local()), "local-1")
assertEquals(1, queued.size)
assertTrue(queued.single().serverAccepted)
}
@Test
fun discarding_a_failed_send_removes_only_that_local_copy() {
val server = QueuedMessage("server-1", "already accepted", emptyList())
val queued = listOf(local(), local("keep this one").copy(id = "local-2"), server)
val discarded = discardPendingMessage(queued, "local-1")
assertEquals(listOf("local-2", "server-1"), discarded.map { it.id })
}
@Test
fun identical_messages_are_reconciled_one_at_a_time() {
val queued = listOf(local(), local().copy(id = "local-2"))
val afterFirst =
reconcileUserMessage(
queued,
SessionEvent.UserMessage("keep this", id = null, attachments = emptyList()),
)
assertEquals(listOf("local-2"), afterFirst.map { it.id })
}
}
@@ -1,111 +0,0 @@
package com.example.aiapp
import androidx.compose.ui.geometry.Offset
import kotlin.test.Test
import kotlin.test.assertEquals
class SessionImageTest {
@Test
fun `a full-height image hides both bars`() {
assertEquals(
ViewerBars(status = true, navigation = true),
viewerBars(
imageWidth = 1000,
imageHeight = 2000,
viewportWidth = 1000,
viewportHeight = 2000,
scale = 1f,
offset = Offset.Zero,
insets = ViewerBarInsets(status = 100, navigation = 100),
),
)
}
@Test
fun `a letterboxed image leaves both bars visible`() {
assertEquals(
ViewerBars(),
viewerBars(
imageWidth = 1000,
imageHeight = 500,
viewportWidth = 1000,
viewportHeight = 2000,
scale = 1f,
offset = Offset.Zero,
insets = ViewerBarInsets(status = 100, navigation = 100),
),
)
}
@Test
fun `panning into the status bar hides only that bar`() {
assertEquals(
ViewerBars(status = true),
viewerBars(
imageWidth = 1000,
imageHeight = 500,
viewportWidth = 1000,
viewportHeight = 2000,
scale = 2f,
offset = Offset(0f, -500f),
insets = ViewerBarInsets(status = 100, navigation = 100),
),
)
}
@Test
fun `native scale reverses fitting a tall image`() {
assertEquals(
1.25f,
nativeScale(
imageWidth = 1000,
imageHeight = 2000,
viewportWidth = 1000,
viewportHeight = 1600,
),
)
}
@Test
fun `native scale leaves a small image alone`() {
assertEquals(
1f,
nativeScale(
imageWidth = 500,
imageHeight = 500,
viewportWidth = 1000,
viewportHeight = 1000,
),
)
}
@Test
fun `zoom keeps the region panned to in the center`() {
assertEquals(
Offset(240f, -160f),
zoomOffset(
offset = Offset(120f, -80f),
centroid = Offset(500f, 1000f),
pan = Offset.Zero,
oldScale = 2f,
newScale = 4f,
viewportCenter = Offset(500f, 1000f),
),
)
}
@Test
fun `zoom keeps an off-center pinch beneath moving fingers`() {
assertEquals(
Offset(460f, 420f),
zoomOffset(
offset = Offset(100f, -100f),
centroid = Offset(250f, 400f),
pan = Offset(10f, 20f),
oldScale = 2f,
newScale = 4f,
viewportCenter = Offset(500f, 1000f),
),
)
}
}
@@ -1,137 +0,0 @@
package com.example.aiapp
import java.time.OffsetDateTime
import kotlin.test.Test
import kotlin.test.assertEquals
class SessionUsageTest {
@Test
fun `usage snapshots stay with the session's machine and provider`() {
val claude = snapshot("machine", "claude", null)
val codex = snapshot("machine", "codex", "codex")
val reserve = snapshot("machine", "codex", "gpt-reserve")
val elsewhere = snapshot("other", "codex", "codex")
assertEquals(
listOf(codex, reserve),
usageSnapshotsFor(
listOf(claude, codex, reserve, elsewhere),
machine = "machine",
provider = "codex",
),
)
}
@Test
fun `a session without a meter has no usage snapshots`() {
assertEquals(
emptyList(),
usageSnapshotsFor(
listOf(snapshot("machine", "claude", null)),
machine = "machine",
provider = null,
),
)
}
@Test
fun `the model selects its named pool and other models use the generic pool`() {
val generic = snapshot("machine", "codex", "codex")
val spark =
snapshot(
"machine",
"codex",
"codex_bengalfox",
limitName = "GPT-5.3-Codex-Spark",
)
val reserve =
snapshot("machine", "codex", "base_model_inference", limitName = "gpt-reserve")
val pools = listOf(generic, spark, reserve)
assertEquals(spark, usagePoolFor(pools, "gpt-5.3-codex-spark"))
assertEquals(reserve, usagePoolFor(pools, "gpt-5.6-luna"))
assertEquals(generic, usagePoolFor(pools, "gpt-6-astra"))
}
@Test
fun `the bar uses the shortest reported cycle`() {
val weekly = window("Weekly", 10_080)
val hourly = window("5-hour window", 300)
assertEquals(hourly, shortestUsageWindow(listOf(weekly, hourly)))
assertEquals(null, shortestUsageWindow(listOf(window("unknown", null))))
}
@Test
fun `the time cursor follows elapsed time through the window`() {
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
assertEquals(
0.4f,
usageWindowElapsedFraction(
window("5-hour window", 300, "2026-09-17T15:00:00Z"),
now,
),
)
}
@Test
fun `the time cursor clamps at the window ends`() {
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
assertEquals(
0f,
usageWindowElapsedFraction(
window("5-hour window", 300, "2026-09-17T18:00:00Z"),
now,
),
)
assertEquals(
1f,
usageWindowElapsedFraction(
window("5-hour window", 300, "2026-09-17T11:00:00Z"),
now,
),
)
}
@Test
fun `the time cursor is absent without a usable duration and reset time`() {
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
assertEquals(null, usageWindowElapsedFraction(window("unknown", null), now))
assertEquals(null, usageWindowElapsedFraction(window("not running", 300), now))
assertEquals(
null,
usageWindowElapsedFraction(window("unreadable", 300, "not a timestamp"), now),
)
assertEquals(null, usageWindowElapsedFraction(window("zero", 0), now))
}
private fun snapshot(
machine: String,
provider: String,
limitId: String?,
limitName: String? = null,
) =
UsageSnapshot(
provider = provider,
machine = machine,
machineName = machine,
limitId = limitId,
limitName = limitName,
state = "ok",
detail = null,
windows = emptyList(),
)
private fun window(label: String, durationMinutes: Long?, resetsAt: String? = null) =
UsageWindow(
kind = "test",
label = label,
percent = 12.0,
durationMinutes = durationMinutes,
resetsAt = resetsAt,
active = false,
)
}
@@ -1,150 +0,0 @@
package com.example.aiapp
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The model's working as its own row, and the line under a finished reply.
*
* Both have the same shape of hazard: a state nothing measured must not come out looking like one
* that was. A block interrupted mid-thought has no duration, and a provider that reports no
* generation speed has no figure -- neither may borrow one.
*/
class ThinkingTest {
private val utc = ZoneId.of("UTC")
private var seq = 0L
private fun fold(items: List<TranscriptItem>, event: SessionEvent, ts: Double = 1.0) =
foldEvent(items, SeqEvent(seq = ++seq, ts = ts, event = event))
private fun fold(vararg events: SessionEvent) =
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
private fun thinking(items: List<TranscriptItem>) =
items.filterIsInstance<TranscriptItem.ThinkingRow>()
@Test
fun `deltas accumulate into one block that ends with its duration`() {
val items =
fold(
SessionEvent.Thinking("the user "),
SessionEvent.Thinking("wants a card"),
SessionEvent.ThinkingDone(12_400),
SessionEvent.AssistantText("Here it is."),
)
val block = thinking(items).single()
assertEquals("the user wants a card", block.text)
assertEquals(12_400, block.ms)
assertEquals("Thought for 12.4s", thinkingHeadline(block))
// Its own row, above the reply rather than inside it.
assertEquals(1, items.filterIsInstance<TranscriptItem.AssistantMsg>().size)
}
@Test
fun `a block the turn ended in the middle of stops without naming a span`() {
val items = fold(SessionEvent.Thinking("half a thought"), SessionEvent.Status("idle"))
val block = thinking(items).single()
assertNull(block.ms)
assertTrue(!block.open)
assertEquals("Thought", thinkingHeadline(block))
}
@Test
fun `a block still being thought says so`() {
val block = thinking(fold(SessionEvent.Thinking("hmm"))).single()
assertTrue(block.open)
assertEquals("Thinking", thinkingHeadline(block))
}
@Test
fun `thinking between two replies is two replies and two blocks`() {
val items =
fold(
SessionEvent.Thinking("first"),
SessionEvent.ThinkingDone(1_000),
SessionEvent.AssistantText("One."),
SessionEvent.Thinking("second"),
SessionEvent.ThinkingDone(2_000),
SessionEvent.AssistantText("Two."),
)
assertEquals(listOf("first", "second"), thinking(items).map { it.text })
assertEquals(
listOf("One.", "Two."),
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text },
)
}
@Test
fun `a reply carries when it was sent and what it cost to produce`() {
val items =
fold(emptyList(), SessionEvent.AssistantText("Done."), ts = 1_788_609_600.0).let {
fold(it, SessionEvent.UsageDelta(42, 100, 18.37, 9_489))
}
val reply = items.filterIsInstance<TranscriptItem.AssistantMsg>().single()
assertEquals(1_788_609_600.0, reply.ts)
assertEquals(18.37, reply.tokensPerSecond)
assertEquals(9_489, reply.prefillMs)
val footer = replyFooterText(reply.ts, reply.tokensPerSecond, reply.prefillMs, utc)
// The clock reading rather than the whole string: the platform's own short-time format
// differs by JDK and locale, which is the point of asking it for one.
assertTrue(footer!!.startsWith("read 9.5s · 18.4 tok/s · "), footer)
assertTrue(footer.contains("12:00"), footer)
}
@Test
fun `the clock stays at the end however much the provider measured`() {
// What a provider that measures nothing leaves: the time, and nothing in front of it.
val bare = replyFooterText(1_788_609_600.0, null, null, utc)
assertTrue(bare!!.contains("12:00"), bare)
assertTrue(!bare.contains("tok/s") && !bare.contains("read"), bare)
// Every shape ends with the same thing, which is the whole point of the order: the clock
// does not move because the session is on a provider that measures more or less.
val shapes =
listOf(
bare,
replyFooterText(1_788_609_600.0, 18.37, null, utc)!!,
replyFooterText(1_788_609_600.0, null, 9_489, utc)!!,
replyFooterText(1_788_609_600.0, 18.37, 9_489, utc)!!,
)
assertEquals(1, shapes.map { it.substringAfterLast("· ") }.distinct().size, "$shapes")
// A reply with nothing to say has no line at all rather than an empty one.
assertNull(replyFooterText(0.0, null, null, utc))
}
@Test
fun `a block cut by a page boundary is one block, and it is not still going`() {
// Each page folded on its own, as the app does: the older one holds the fragments before
// the cut and no ending, the newer one the rest and the ending.
val older = fold(SessionEvent.Thinking("half a "))
val newer = fold(SessionEvent.Thinking("thought"), SessionEvent.ThinkingDone(2_000))
val joined = joinPages(older, newer)
val block = thinking(joined).single()
assertEquals("half a thought", block.text)
assertEquals(2_000, block.ms)
assertTrue(!block.open)
}
@Test
fun `two blocks meeting at a page boundary stay two`() {
val older = fold(SessionEvent.Thinking("first"), SessionEvent.ThinkingDone(1_000))
val newer = fold(SessionEvent.Thinking("second"), SessionEvent.ThinkingDone(2_000))
assertEquals(listOf("first", "second"), thinking(joinPages(older, newer)).map { it.text })
}
@Test
fun `usage that lands after a tool call is not folded onto an older reply`() {
val items =
fold(
SessionEvent.AssistantText("Reading it."),
SessionEvent.ToolStart("t1", "Read", "{}"),
SessionEvent.ToolEnd("t1", "done"),
SessionEvent.UsageDelta(42, 100, 18.0, 500),
)
assertNull(items.filterIsInstance<TranscriptItem.AssistantMsg>().single().tokensPerSecond)
}
}
@@ -1,43 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ToolInputTest {
@Test
fun `bash wrapper ignores double quotes inside its outer pair`() {
assertEquals(
"rg -n \"needle\" server app",
renderedBashScript("/usr/bin/bash -lc \"rg -n \"needle\" server app\""),
)
}
@Test
fun `bash wrapper ignores single quotes inside its outer pair`() {
assertEquals(
"printf 'hello'",
renderedBashScript("/bin/bash -lc 'printf 'hello''"),
)
}
@Test
fun `unquoted or unfamiliar commands stay intact`() {
assertNull(renderedBashScript("/usr/bin/bash -lc echo hello"))
assertNull(renderedBashScript("/usr/bin/fish -lc 'echo hello'"))
}
@Test
fun `missing optional input is not displayed as null`() {
assertTrue(parseToolInput("TaskOutput", "null").rest.isEmpty())
}
@Test
fun `collaboration calls say what they do and omit empty completion`() {
assertEquals("Spawn agent", toolDisplayName("Task"))
assertEquals("Wait for agents", toolDisplayName("TaskOutput"))
assertEquals("", toolDisplayOutput("TaskOutput", "completed"))
assertEquals("failed", toolDisplayOutput("TaskOutput", "failed"))
}
}
@@ -1,167 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* How a run of tool calls is cut into rows: the call still running, the last call in the
* transcript, and one held out because the reader has it open are drawn on their own, and every
* piece the cut leaves behind still has a key of its own -- two rows sharing one key take the app
* down, and a key that moves takes the reader's place with it.
*/
class ToolRowsTest {
private var seq = 0L
private fun call(id: String, runId: String = id, done: Boolean = true) =
TranscriptItem.ToolRun(
seq = ++seq,
id = id,
runId = runId,
tool = "Bash",
input = "{}",
output = if (done) "ok" else "",
done = done,
)
/** Something that is not a tool call, to put behind the run so its last call folds in. */
private fun reply() = TranscriptItem.AssistantMsg(seq = ++seq, text = "done")
private fun shape(rows: List<TranscriptRow>) = rows.map { row ->
when (row) {
is TranscriptRow.Tools -> row.calls.map { it.id }
is TranscriptRow.Single -> listOf((row.item as? TranscriptItem.ToolRun)?.id ?: "reply")
}
}
private fun assertKeysDistinct(rows: List<TranscriptRow>) =
assertEquals(rows.size, rows.map { it.key }.toSet().size, "$rows")
@Test
fun the_call_still_running_is_a_row_of_its_own() {
val rows =
groupToolRuns(
listOf(
call("a"),
call("b", runId = "a"),
call("c", runId = "a", done = false),
call("d", runId = "a"),
reply(),
)
)
assertEquals(
listOf(listOf("a", "b"), listOf("c"), listOf("d"), listOf("reply")),
shape(rows),
)
assertKeysDistinct(rows)
}
@Test
fun a_call_running_in_the_middle_of_its_run_splits_the_group_in_two() {
val rows =
groupToolRuns(
listOf(
call("a"),
call("b", runId = "a", done = false),
call("c", runId = "a"),
call("d", runId = "a"),
reply(),
)
)
assertEquals(
listOf(listOf("a"), listOf("b"), listOf("c", "d"), listOf("reply")),
shape(rows),
)
assertKeysDistinct(rows)
}
/**
* A call held out is one the reader opened while it stood on its own; being overtaken while
* they read it does not fold it away, and closing it hands it back to its run.
*/
@Test
fun a_held_out_call_stays_out_of_its_group() {
val calls =
listOf(
call("a"),
call("b", runId = "a"),
call("c", runId = "a"),
call("d", runId = "a"),
reply(),
)
val whileHeld = groupToolRuns(calls, heldOut = setOf("d"))
val afterItCloses = groupToolRuns(calls)
assertEquals(listOf(listOf("a", "b", "c"), listOf("d"), listOf("reply")), shape(whileHeld))
assertTrue(whileHeld[1] is TranscriptRow.Single, "$whileHeld")
assertKeysDistinct(whileHeld)
assertEquals(listOf(listOf("a", "b", "c", "d"), listOf("reply")), shape(afterItCloses))
assertTrue(afterItCloses.first() is TranscriptRow.Tools, "$afterItCloses")
}
/**
* The one case where the run's name is a call that is not in the run's first row: a page of
* history joined onto a run whose own first call is still going ([joinPages] renames the older
* calls to the newer run's name). Both rows would key on that name.
*/
@Test
fun the_run_keeps_its_name_even_when_the_call_it_is_named_after_is_the_one_running() {
val rows = groupToolRuns(listOf(call("a", runId = "b"), call("b", done = false)))
assertEquals(listOf(listOf("a"), listOf("b")), shape(rows))
assertKeysDistinct(rows)
assertEquals("b", rows.first().key)
}
@Test
fun a_run_that_reappears_after_another_row_keeps_distinct_keys() {
val rows =
groupToolRuns(
listOf(
call("older", runId = "exec-1"),
call("older-2", runId = "exec-1"),
reply(),
call("exec-1", runId = "exec-1"),
call("newer", runId = "exec-1"),
reply(),
)
)
assertTrue(rows[0] is TranscriptRow.Tools, "$rows")
assertTrue(rows[2] is TranscriptRow.Tools, "$rows")
assertKeysDistinct(rows)
assertEquals("exec-1", rows[0].key)
assertEquals("exec-1/exec-1", rows[2].key)
}
/**
* Finishing is not what folds a call back in -- being overtaken is. A session that has run its
* last command and is writing its reply leaves that command standing until the reply starts.
*/
@Test
fun the_last_call_stays_out_when_it_finishes_and_folds_in_when_something_follows() {
val a = call("a")
val running = call("b", runId = "a", done = false)
val finished = running.copy(done = true)
val whileRunning = groupToolRuns(listOf(a, running))
val afterItEnds = groupToolRuns(listOf(a, finished))
val afterTheReply = groupToolRuns(listOf(a, finished, reply()))
assertEquals(listOf(listOf("a"), listOf("b")), shape(whileRunning))
assertEquals(listOf(listOf("a"), listOf("b")), shape(afterItEnds))
assertEquals(listOf(listOf("a", "b"), listOf("reply")), shape(afterTheReply))
// The run keeps the key it was drawn under throughout, so the list rebuilds a row rather
// than losing its anchor.
assertEquals(whileRunning.first().key, afterItEnds.first().key)
assertEquals(whileRunning.first().key, afterTheReply.first().key)
}
@Test
fun a_run_of_finished_calls_is_one_group_once_something_follows_it() {
val rows =
groupToolRuns(
listOf(call("a"), call("b", runId = "a"), call("c", runId = "a"), reply())
)
assertEquals(listOf(listOf("a", "b", "c"), listOf("reply")), shape(rows))
assertTrue(rows.first() is TranscriptRow.Tools, "$rows")
}
}
@@ -23,7 +23,7 @@ class TranscriptCacheTest {
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it } private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
private fun session(id: String = "s") = cache().session(TranscriptAddress(id)) private fun session(id: String = "s") = cache().session(id)
private fun line(seq: Long, type: String = "toolStart") = private fun line(seq: Long, type: String = "toolStart") =
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}""" """{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
@@ -1,185 +0,0 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Where one turn ends and the next begins, which is the part of the fold that had no way of saying
* anything was wrong: two replies run together read as one long answer, and the seam is somewhere
* in the middle of a sentence.
*/
class TranscriptItemsTest {
private var seq = 0L
private fun fold(items: List<TranscriptItem>, event: SessionEvent) =
foldEvent(items, SeqEvent(seq = ++seq, ts = 1.0, event = event))
private fun fold(vararg events: SessionEvent) =
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
private fun texts(items: List<TranscriptItem>) =
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text }
@Test
fun an_authentication_failure_stays_visible_as_an_error_row() {
val entry =
SeqEvent(
seq = 7,
ts = 1.0,
event = SessionEvent.AuthenticationRequired("sign in again"),
)
assertEquals(
TranscriptItem.ErrorMsg(7, "sign in again"),
foldEvent(emptyList(), entry).single(),
)
}
@Test
fun text_after_the_turn_ended_is_a_new_reply_rather_than_more_of_the_last_one() {
val items =
fold(
SessionEvent.AssistantText("You'll get the one-line notice when it lands."),
SessionEvent.Status("idle"),
SessionEvent.AssistantText("Dev Updater fix is pushed."),
)
assertEquals(
listOf("You'll get the one-line notice when it lands.", "Dev Updater fix is pushed."),
texts(items),
)
}
@Test
fun deltas_of_one_reply_still_accumulate_into_it() {
val items =
fold(
SessionEvent.AssistantText("Still "),
SessionEvent.AssistantText("running "),
SessionEvent.Status("running"),
SessionEvent.AssistantText("its tests."),
)
assertEquals(listOf("Still running its tests."), texts(items))
}
@Test
fun completed_text_replaces_provisional_deltas_live() {
val items =
fold(
SessionEvent.AssistantText("I'll inspect the color-c concrete implementation"),
SessionEvent.AssistantTextFinal(
"Ill inspect the color-correction TODO and the relevant design."
),
)
assertEquals(
listOf("Ill inspect the color-correction TODO and the relevant design."),
texts(items),
)
}
@Test
fun completed_text_discards_provisional_deltas_across_a_page_boundary() {
val earlier = fold(SessionEvent.AssistantText("1. provisional section\n\n"))
val later =
fold(
SessionEvent.AssistantTextFinal("1. final first section\n\n2. final second section")
)
assertEquals(
listOf("1. final first section\n\n2. final second section"),
texts(joinPages(earlier, later)),
)
}
@Test
fun a_final_value_after_a_settled_reply_is_a_new_message_across_a_page_boundary() {
val earlier =
fold(
SessionEvent.AssistantText("Previous answer."),
SessionEvent.Status("idle"),
)
val later = fold(SessionEvent.AssistantTextFinal("Next answer."))
assertEquals(
listOf("Previous answer.", "Next answer."),
texts(joinPages(earlier, later)),
)
}
/**
* The rule that replaced the wall of reports. A turn that starts with nothing recorded in front
* of it -- a subagent finishing, the CLI picking a conversation back up -- leaves two replies
* abutting, and only the break says they are two.
*/
@Test
fun two_replies_that_meet_are_separated_by_a_rule_and_nothing_else() {
val items =
fold(
SessionEvent.AssistantText("Launched it."),
SessionEvent.Status("waiting"),
SessionEvent.AssistantText("Noted."),
)
assertEquals(3, items.size, "$items")
assertTrue(items[1] is TranscriptItem.TurnBreak, "$items")
assertEquals(listOf("Launched it.", "Noted."), texts(items))
// Distinct keys: the break shares the reply's seq, and two items with one key take the
// app down.
assertEquals(3, items.map { it.key }.toSet().size, "$items")
}
/**
* A reply after anything that draws a row of its own needs no rule: that row is the boundary.
*/
@Test
fun a_reply_after_a_row_of_its_own_gets_no_rule() {
val items =
fold(
SessionEvent.AssistantText("Launched it."),
SessionEvent.Status("idle"),
SessionEvent.UserMessage("carry on", null, emptyList()),
SessionEvent.AssistantText("Noted."),
)
assertTrue(items.none { it is TranscriptItem.TurnBreak }, "$items")
}
@Test
fun a_repeated_tool_start_is_still_one_row() {
val start = SessionEvent.ToolStart("exec-1", "Bash", "{\"command\":\"cargo test\"}")
val items =
fold(
start,
SessionEvent.AssistantText("The test run is still going."),
start,
SessionEvent.ToolEnd("exec-1", "finished"),
)
val tools = items.filterIsInstance<TranscriptItem.ToolRun>()
assertEquals(1, tools.size, "$items")
assertEquals("finished", tools.single().output)
assertTrue(tools.single().done)
}
/**
* The page-join half of the same rule. A boundary that cuts one reply leaves an unfinished half
* to be rejoined; a boundary that lands between two turns must not join anything, or paging
* back puts the run-together paragraph straight back.
*/
@Test
fun paging_back_rejoins_a_cut_reply_and_leaves_two_finished_ones_apart() {
val cut =
joinPages(
listOf(TranscriptItem.AssistantMsg(1, "half a ")),
listOf(TranscriptItem.AssistantMsg(2, "sentence", settled = true)),
)
assertEquals(listOf("half a sentence"), texts(cut))
val whole =
joinPages(
listOf(TranscriptItem.AssistantMsg(1, "One turn.", settled = true)),
listOf(TranscriptItem.AssistantMsg(2, "The next.", settled = true)),
)
assertEquals(listOf("One turn.", "The next."), texts(whole))
// And the rule between them, which the fold that would have made it never got to see.
assertTrue(whole.any { it is TranscriptItem.TurnBreak }, "$whole")
}
}
-5
View File
@@ -45,11 +45,6 @@ GLYPHS=(
U+F0193 # md-content_save U+F0193 # md-content_save
U+F0224 # md-file_outline U+F0224 # md-file_outline
U+F201 # fa-line_chart -- Font Awesome's, asked for by name U+F201 # fa-line_chart -- Font Awesome's, asked for by name
U+F035C # md-menu -- the burger, as a row's drag handle
U+F07B7 # md-console_line -- a backgrounded command
U+F06A9 # md-robot -- a subagent
U+F04AA # md-sitemap -- a workflow
U+F0625 # md-help_circle_outline -- a background task of a kind this build does not know
) )
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
+2 -2
View File
@@ -120,10 +120,10 @@ TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; } api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
echo "==> Importing" echo "==> Importing"
MACHINE=$(api "https://127.0.0.1:$PORT/machines" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) SETUP=$(api "https://127.0.0.1:$PORT/setups" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
SESSION=$(api -H 'Content-Type: application/json' -X POST \ SESSION=$(api -H 'Content-Type: application/json' -X POST \
"https://127.0.0.1:$PORT/sessions" \ "https://127.0.0.1:$PORT/sessions" \
-d "{\"machine\":\"$MACHINE\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \ -d "{\"setup\":\"$SETUP\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events" echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events"
+1 -1
View File
@@ -9,7 +9,7 @@
# emulator" is three places for the memory check that was missing from all of # emulator" is three places for the memory check that was missing from all of
# them. # them.
# #
# Environment machine (SDK location, PATH, ...) lives in ./android-env.sh, # Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
# which can also be sourced directly for one-off commands. # which can also be sourced directly for one-off commands.
set -eu set -eu
+5
View File
@@ -17,6 +17,11 @@ dependencyResolutionManagement {
include(":androidApp") include(":androidApp")
// E3 (RUST.md): the Kotlin/Java shell over android-shell's JNI bridge, a
// separate module from :androidApp so the ~13,000 lines of working Compose
// UI there are untouched. See shellApp/build.gradle.kts's module comment.
include(":shellApp")
// The app half of wg-app-link, resolved by path through the submodule so // The app half of wg-app-link, resolved by path through the submodule so
// this checkout and the crate it consumes move together -- the same // this checkout and the crate it consumes move together -- the same
// arrangement `server/` uses for the Rust half. See that repo's README. // arrangement `server/` uses for the Rust half. See that repo's README.
+163
View File
@@ -0,0 +1,163 @@
plugins { alias(libs.plugins.androidApplication) }
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
// into Rust (`../../android-shell`). Deliberately its own module rather
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
// of working Compose UI this experiment does not touch, and the two can be
// installed side by side on the same development device (see
// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme
// and Keystore alias are not the production app's). No Compose plugin, no
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
// Java, and the CA constant below is generated as Java too.
//
// The CA this build pins is baked in the same way `androidApp`'s does --
// see that module's `build.gradle.kts` comment for the reasoning (the
// trust boundary follows the machine that builds, never a pasted copy).
// `PinnedCa.java`'s package must match `android-shell`'s
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
val pinnedCaPath: String =
System.getenv("AI_APP_CA")
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
"/ai-app/certs/ca.pem"
abstract class GeneratePinnedCa : DefaultTask() {
@get:Input abstract val caPath: Property<String>
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.NONE)
abstract val caCertificate: RegularFileProperty
@get:OutputDirectory abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val path = caPath.get()
val ca = File(path)
if (!ca.isFile) {
throw GradleException(
"No CA certificate at $path.\n" +
"Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it " +
"generates the CA this build pins.\n" +
"Set AI_APP_CA=/path/to/ca.pem to build against a different one."
)
}
val pem = ca.readText().trim()
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
throw GradleException("$path is not a PEM certificate.")
}
val dir = outputDir.get().dir("com/example/aiapp/shell").asFile
dir.mkdirs()
// Same reasoning as androidApp's generatePinnedCert: the text block
// must start immediately after the opening `"""`, or
// CertificateFactory stops recognising the "-----BEGIN" preamble.
File(dir, "PinnedCa.java")
.writeText(
"""
|// Generated from $path by the generatePinnedCa task. Do not edit.
|package com.example.aiapp.shell;
|
|public final class PinnedCa {
| private PinnedCa() {}
| public static final String PINNED_CA_PEM = ""${'"'}
|$pem""${'"'};
|}
|"""
.trimMargin()
)
}
}
val generatePinnedCa =
tasks.register<GeneratePinnedCa>("generatePinnedCa") {
val ca = file(pinnedCaPath)
caPath.set(pinnedCaPath)
if (ca.isFile) {
caCertificate.set(ca)
}
}
android {
namespace = "com.example.aiapp.shell"
compileSdk = 37
defaultConfig {
applicationId = "com.example.aiapp.shell"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
// Same reasoning and same key as androidApp's (see that module's comment): E5 (RUST.md)
// signs its own, Gradle-free build with this same keystore, and the two can only
// `adb install -r` over each other if they carry the same certificate.
val keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
create("release") {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
if (keystore != null) signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
// E5 (RUST.md): the xtask dexes and packages this module's Java sources itself, but it does
// not resolve Maven dependencies -- reimplementing a dependency resolver was out of scope for a
// packaging step, so this one task is the single place Gradle still runs in that pipeline. It
// asks the dependency graph for the *post-transform* jars (AARs already unpacked to a classes
// jar, the same artifact type AGP's own dexing task consumes) rather than the raw configuration,
// which would hand back .aar files d8 cannot read directly.
val artifactType = Attribute.of("artifactType", String::class.java)
tasks.register("printRuntimeClasspathJars") {
description = "Writes the resolved release runtime classpath jars, one per line, for xtask."
val outputFile = layout.buildDirectory.file("xtask/runtime-classpath.txt")
outputs.file(outputFile)
val jars =
configurations
.getByName("releaseRuntimeClasspath")
.incoming
.artifactView { attributes.attribute(artifactType, "android-classes-jar") }
.files
// Captured as a plain FileCollection (not the ArtifactView itself, which the
// configuration cache cannot serialize) so this task is still cacheable.
inputs.files(jars)
doLast {
val file = outputFile.get().asFile
file.parentFile.mkdirs()
file.writeText(jars.joinToString("\n") { it.absolutePath })
}
}
androidComponents {
onVariants { variant ->
variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir)
}
}
dependencies {
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
// android-shell's settings.rs calls into this Kotlin class directly
// over JNI rather than re-sealing the token in Rust; see that file's
// module doc.
implementation(project(":link"))
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
// ServiceCompat -- android-shell's notify.rs calls these classes over
// JNI so the pre-26 fallback behaviour (no channels) lives once, in
// the library that already has it, rather than being re-derived as a
// set of Build.VERSION.SDK_INT branches in Rust.
implementation(libs.androidx.core.ktx)
}
Loaded 100 of 302 files, more files were not shown because too many files have changed in this diff. Show more