117 Commits
Author SHA1 Message Date
iris-aiandClaude Opus 5 53fc59a946 Draw a model's thinking as markdown
A model reasons in the same headings, lists and fenced code it answers in,
so plain text put rows of hashes and asterisks around the working the reader
opened the card to read. The card now draws `MarkdownText`, live while the
block is still open so a delta costs a parse of its last block rather than
of the whole reasoning, and the words answer the card's own tap through
`LocalMarkdownTap` the way a memory note's do.

A settled block is warmed with the rest of a page; an open one deliberately
is not, since warming a prefix per delta is a parse of the block per delta
held for ever. Echo's `/think` fixture is markdown now, because a fixture of
flat prose exercises none of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 22:43:45 -04:00
iris-ai 3dbf04f5ec One control per kind of setting, and no paragraphs under any of them
Three explanatory paragraphs were left on the session settings screen: what
auto-resume does, what Move costs, what changing the thinking level costs.
The last two are consequences of an action, so they are `RestartDialog` now --
the same question the machines tab already asks before it reloads a model, and
now in one file rather than private to that screen. The first is gone; the
switch beside it says what it is.

Thinking was two controls: a row of chips for a llama session, where it is a
provider-declared choice, and a picker button for a Claude session, where it
is the session's own effort. One kind of information drawn two ways, decided
by which code path the value came down. A choice is `PickerRow` in a list of
settings and `ChipGroup` on a form being filled in, and which of the two is
the screen's to say rather than the provider's.

`warnAboutRestart` went with them. It marked a control "(on restart)" and
wrote a sentence under the form, and it had nothing to mark: every session
param is `restart: false` and every model param is `restart: true`. The field
now decides whether saving a model's settings stops to ask, which is the
question it was always about.

The wait's bar keeps the status row's own margin instead of running to the
edges of the glass.

Checked on the emulator against the sandbox: an echo, a claude-cli and a
llama session, each with no paragraph left under a setting and thinking drawn
the same way in all three; "Think high?", "Move to /tmp?" and the model
settings dialog all stop to ask. ktfmt, compile, lint and the unit tests are
clean.
2026-09-21 12:25:49 -04:00
iris-ai 5626a7d595 A setting is a title and a box, and a wait's bar spans the screen
A field's hint had a grey line of its own between the label and the box, so a
form of a dozen settings was mostly explanation. It is the empty box's
placeholder now, where it costs no height and says the same thing to anybody
who has not typed yet.

The wait's bar was drawn in whatever width the status row had left beside its
words, which is where the context figure goes -- so a session loading a model
or reading a prompt stopped saying how full it was, at the moment somebody is
watching it fill. The bar is the whole width above the row instead, the
compaction's indeterminate one with it, and the percentage joins the status
word ("reading prompt 30%") rather than replacing the context. The row keeps
the bar's height when there is no bar: it sits above the transcript and the
box, and one that came and went would move both under the reader's thumb
every time a turn started.

Checked on the emulator against the sandbox's progress rig: /loading and
/reading both draw a full-width bar with "context unknown" still beside them,
and the settings form has no grey lines left between a label and its box.
ktfmt, compile, lint and the unit tests are clean.
2026-09-21 12:13:08 -04:00
iris-ai 1aac22bfc9 Say a transcript's size, name a model by something, and ask before a reload
Five things asked for on the phone, and one trap behind the first of them.

A llama prompt carries `<__media__>` where a picture was, and llama.cpp
pairs each marker with a decoded image when it tokenizes -- so a marker in
words nobody attached a picture to fails the turn, and then fails every
later one, since the conversation is folded out of a transcript that holds
it for good. A model saying the marker back is enough to do it. Every
message with words in it now goes through `without_marker`.

A model's `general.name` is filled in by whatever converted the file, and
`convert_hf_to_gguf.py` fills it from the directory it converted: Prism ML's
Bonsai publishes `general.name = "Hf"`, which is unique and so passed the
label cascade and told a reader nothing. A name is now used only where it
shares a word with the repo or the file it came from; one that does not
drops to the file name.

The model settings dialog and the server card said what a save would cost in
a paragraph under the control, read after the decision if at all. Both ask
instead, in the shape the rest of that screen already uses for Stop and
Delete -- and the dialog's question is asked over the edits, so Cancel comes
back to them.

A field's label was `labelMedium` in the variant colour while every setting
beside it was body text, which on one form read as two ranks of setting.

`GET /sessions/{id}`'s `transcriptFile` now carries the file's size, and the
settings screen draws it beside what this phone has cached.

Checked on the emulator against the sandbox: the labels line up, the row
says "14 kB · 14 kB cached", and both confirmations appear over a loaded
Qwen3-0.6B. `cargo test` 275 passed, clippy and fmt clean, lint clean.
2026-09-21 11:59:26 -04:00
iris-ai 4b5ed6e398 Review fixes: keep the keyboard off the settings form, and key the waits apart
The settings dialog was a dialog, so the platform moved it off the
keyboard; a screen is not, and the lower half of the form was under it.

A tool call registered its abandonable wait under the call's own id, which
the model chooses -- one named `generating` would have taken the turn's
entry and left the other wait with nobody to answer it.
2026-09-21 03:39:06 -04:00
iris-ai 7278a58387 Settings as a screen with two tabs, and fields that cost one line
The settings dialog had outgrown a dialog: it scrolled inside itself,
covered the session it is about, and had nowhere to put a second tab. It
is a screen now, drawn over the session like the file explorer so the
session under it stays composed, with the back gesture to leave it. 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.

Every text field in the app goes through LabelledField: the label is a
line above the box rather than a thing floating inside it, the hint says
what leaving it blank means, and the padding is one line's worth.
Material's outlined field spends the height of three lines to hold one,
which on a form of a dozen settings is a screen and a half of scrolling.
The value's own text is unchanged -- the framing was what cost.

A session also gets a system prompt, which for llama.cpp is one entry in
the params table and no app change: it rides in front of the conversation
on every request rather than being recorded as the first thing in it, so
changing it takes effect on the next message. ParamKind::Prose is new
because a paragraph in a one-line box shows six words of itself.
2026-09-21 03:35:54 -04:00
iris-ai 386c1c4def Pause a llama turn at once, and let Stop take the model with it
A turn waits on three things that look nowhere at all: a permission
question, a tool call with a minute to run, and the completion itself,
which says nothing while the prompt is read -- tens of seconds on a long
conversation. Setting a flag left the turn exactly where it was until
whichever it was came back.

The wait is now what ends, not the work. Each of those runs on a thread of
its own and the interrupt answers the wait; the abandoned thread finishes
into a channel nobody is reading. 43ms to end a turn in every state,
measured against a real model -- including mid prompt-processing, which
used to be a twenty-second wait. That makes cancellation a token per turn
rather than 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. The open thinking block moves to
Shared for the same reason -- the thread that knows one is open is no
longer the thread that ends the turn.

Stop, meanwhile, did nothing at all to a llama session: it signals the
session's recorded process and process::stop refuses a Shared one, which
is the whole point of that record -- so the session sat at idle. A Shared
record routes to the driver now, because what stopping means for a session
that borrows the machine's process is the driver's to say. It ends the
turn, says exited itself, and gives up its claim on the model; each live
session claims the model it is on, and the model is unloaded when the last
claim goes. A model another session is using stays where it is.
2026-09-21 03:16:55 -04:00
iris-ai 849c3b599f Don't draw a wait's bar at nothing
A fraction of zero is the absence of a sample, not a measurement of the
work: a bar at nothing for two minutes says the load has not started,
which of one 8 GB into a 12 GB file is false.

Measured raw off the router's event stream today: llama-server reports a
model's load as 0 and then 1 with nothing in between, on both the 0.6B and
the 27B, and reports prompt processing once a batch. So the bar now draws
for a long prompt -- the wait worth watching -- and a load that says
nothing draws none, which is what not knowing should look like.
2026-09-21 03:01:02 -04:00
iris-ai 66b3403a71 Read a session's transcript as the file it is
The conversation on screen is a drawing of the record, and when the two
disagree -- or when something in the record is what has gone wrong -- there
was no way to see the record itself from the phone.

View raw in the session settings dialog opens the file explorer on the
transcript, which is its third caller and needed no new screen: the file
name and path in the header, a back button, and the lines as code. The
session says where the file is, because only the backend knows, and it
names this backend's machine rather than the session's -- for a remote
session those are two different filesystems. Back from the file lands in
the session's own directory, where the log and the process record are.

The paragraph under Reload goes, and both buttons move to a line of their
own: two buttons and a measurement do not fit a phone's width.
2026-09-21 02:51:19 -04:00
iris-ai 049780fda6 Say how far a llama session's wait has got
Both of its waits are measured somewhere and neither reached the phone: a
model coming off disk, which the router publishes on its event stream and
nowhere else, and a prompt being read, which the generation stream will
report when asked. A session now answers GET /sessions/{id}/progress with
{of, fraction, stage?}, one thread per router keeping the load's fraction
per model, and the session screen asks twice a second while it is drawing
a wait that has one.

Asked for rather than emitted: a load reports five times a second, and an
event is a transcript line for ever. The sample says which status it
measures, so one that outlived its wait cannot be drawn under another
word. The phone puts the bar in the status row's free width and the
percentage where the context figure sits -- a row of its own would move
the transcript every time a turn started -- and names the stage where a
model loads more than one file, because the fraction starts again for
each. /loading and /reading in an echo session are the rig.
2026-09-21 02:44:03 -04:00
iris-ai 78f2fe3b79 Make Pause end a llama turn that is running a tool
A llama turn's interrupt set a flag that the streaming loop and the tool
loop check, and nothing else. A tool call looks nowhere at all while it
runs: llama-server runs a shell command to its own timeout, up to a
minute, and the turn sat there for all of it with the phone showing a
Pause that had done nothing.

The wait is now a channel with two writers -- the call's own thread and
the interrupt -- so whichever speaks first decides what the model is
told. The call is left running on the machine and its answer dropped,
since nothing in that protocol takes one back. The same release covers a
permission question, so interrupt, detach and stop share one method.
2026-09-21 02:19:36 -04:00
iris-ai 6ed896f1e1 Record the install trap behind a llama.cpp build that never answers
`llama-server` is a 16 KB launcher against `libllama-server-impl.so`, so a
build installed without a working runpath dies at exec and reaches the phone
as a model that never became ready. `GNUInstallDirs` picks `lib64` on some
distributions while the recorded runpath says `lib`, which is how the two
flags come apart.
2026-09-21 02:05:24 -04:00
iris-ai 7e7910083c Let a machine have more than one llama.cpp
A model whose kernels are not upstream needs the fork that has them, and
the ordinary models still want the ordinary build. Anything under
`~/.local/share/ai-app/llama/<name>/` -- `llama-server`, or the
`bin/llama-server` a `cmake --install --prefix` leaves -- is now
discovered beside the one on PATH and becomes a provider called
`llama-cpp-<name>`, with its own router, preset and model settings.

That keeps the module's security property rather than bending it: the
phone still names no command, because what runs is still decided by what
somebody put on the machine. Each probe answer is tagged with what was
asked for, since two of these are now the same program under different
paths.

Flash attention joins the model settings (`flash-attn` in the preset).
llama.cpp's `auto` stays the default; the control is for a model whose
publisher asks for `on` outright, which Prism ML's ternary Bonsai does.

Verified against the fork built into that directory: discovery answers
`llama-cpp-prism`, the child server is started with `--flash-attn on`,
and Ternary-Bonsai-2-27B PTQ1_0 loads and answers through a session.
2026-09-21 01:37:36 -04:00
iris-ai df48a334f7 Draw a background task's command as code, and frame the card it opens
A backgrounded command in a session's panel was a terminal glyph beside its
words. The glyph said "command" and so did the monospace face, which is the
same fact twice -- so the mark goes and the command is drawn the way every
other verbatim thing in this app is: highlighted, monospace, on the raw
surface. The glyph stays for the kinds whose description is prose, and for a
command a provider never named.

Tapping one landed the tool card against the bottom edge of the screen, since
a reversed list anchors an item by its bottom -- so a tall card arrived at its
last line. A card that fits the viewport is centred now, and one that does not
has its top put at the top, which is where reading it starts. Only for a
journey the reader asked for: a restore still puts them back exactly where
they stopped.

The panel's own close chevron is gone -- the drag, the scrim and Back all
close it -- and the count is drawn even when it is zero, so "nothing is
running" and "nobody has asked yet" stop looking identical. There is then
nothing to expand, so that heading carries no chevron either.

Checked on the emulator against the sandbox: the card centred in a
mid-transcript tap, sat at the newest end where the list clamps, and the
zero heading drew with no control.
2026-09-20 19:40:07 -04:00
iris-ai cedb18e8c1 Draw a background task as what it ran, and go there on a tap
A card in the session's panel said "background command" under every
description -- and for Codex, which names a terminal by a process id and
gives no description at all, that phrase was the whole of every card.

Both halves of the answer are in the transcript rather than in what the
provider says: a driver now reports which tool call its task belongs to
(Claude's `task_started` carries the `tool_use_id`, Codex's terminal list
the `itemId`), and `LiveSession::background_tasks` resolves those ids
against the transcript into a sequence number and, where the provider said
nothing, the command the call was made with. So the card draws the command,
and the kind shrinks to a mark beside it whose name is what a screen reader
is given.

Tapping one goes to that call in the transcript, opened, which is where a
backgrounded command's output already lands -- rather than drawing a second
copy of it beside the panel. The journey is the one a reopened session
already makes to put a reader back where they stopped, now one function
(`travelTo`). It has to release the held backlog first: events arriving
while the reader is away from the newest end are held rather than applied,
so a task started since they scrolled back was in no row at all and the tap
looked like it had done nothing.

Verified against the sandbox on the emulator: the panel draws
`sleep 120 && echo done` for an echo session's `/background`, and tapping
it lands on that Bash card with its output showing.
2026-09-20 18:46:22 -04:00
iris-ai 3b309766d7 Keep a typed path as typed, and expand ~ where it is used
A llama session's tools all answered "failed to spawn process
[exit code: -1]": `llama-server` takes the working directory as an
`x-tool-cwd` header and `chdir`s to it with no shell in the way, so a
`~/…` cwd named a directory of that name. `files::resolve_blocking`
asks the machine that will serve the session what the path is, and the
driver does that once at launch.

The other half is the storing. `~` and `/home/someone` are a path and a
snapshot of where it pointed, and it is the snapshot that breaks when an
account is renamed -- so `machines::tidy` no longer expands one and
`set_cwd` no longer contracts one (`shorten_home` is gone with it). The
identity file is expanded at the point `ssh` is invoked instead.

Spawn now asks the same question of a typed working directory that
`set_cwd` already did: absolute or home-relative, and actually there on
the machine that will run it. It accepted anything, so a typo became a
session whose process could not start, reported later and pointing at
nothing.

Also: a provider written before `mcp_servers` existed adopts the
defaults a probe would give it now, so a machine discovered before
2026-09-19 stops silently having no web search.

Exercised end to end against a real llama session spawned with
`cwd: "~/repos/ai-app/server"`: `exec_shell_command` with `pwd` answered
`/home/bob/repos/ai-app/server`, exit 0.
2026-09-20 17:06:13 -04:00
iris-ai bd9596d782 Let a llama session be shown a picture where the model reads one
A multimodal model is loaded with the `mmproj` found beside its weights --
which is how a repository publishes the pair -- and an attached image rides
in the request as an `image_url` data URI, so it reaches a model on another
machine without the file going there. Nothing is done for a model without a
projector: no captioning, no OCR, no second model.

Whether a session takes pictures is measured rather than assumed:
`/props`'s `modalities.vision` from the server that loaded the model, in
three states, because a model still coming off disk has genuinely not said.
Unknown is offered rather than refused -- a control withheld because nobody
could ask goes missing from sessions that would have taken it. The answer
reaches the phone twice per model as `Event::Images`, so the photo button is
withdrawn the moment a model with vision is left rather than at whatever
later point the session row is fetched again.

A message carrying an image a model cannot read is stopped rather than
stripped: `llama-server` refuses the whole request over one image part, and
a message sent without its picture would be answered as though the picture
had never been mentioned. The phone will not attach one, and the driver
refuses it again at the three moments the answer can first exist -- at the
door, when a message queued behind a loading model is read, and at the tool
boundary a steer enters by. An earlier turn's image folds into a line of
words for a model without vision, so switching a conversation onto one does
not end it.

A projector is filtered out of the models a provider *offers*, since a
session started on one is a server that cannot load it; it stays in the
machine's own model list, where a file on a disk is managed.

Verified against ggml-org/SmolVLM-256M-Instruct-GGUF, local and over ssh:
"In this picture there is a red circle." Switching that session to
Qwen3-0.6B reports `refused`, refuses the next picture with the reason, and
still answers an ordinary message.
2026-09-20 16:19:53 -04:00
iris-ai b7fd18b195 Let the reader put the session list in its own order
Nothing sorts the sessions tab any more. The order is the server's
`sessions` list, which is the reader's arrangement: holding a row puts the
screen in selection mode -- the same gesture and the same bottom bar as the
import tab -- and each card grows a burger handle at its right edge that
drags the row to a new place, with a tick of haptic feedback for each one it
passes.

The two attempts this replaces, sorting by activity and then by when each
agent was turned on, were both looking for an order a session could not move
itself out of; no rule computed from what a session is doing can be one.
`POST /sessions/order` rewrites the config's order, so it is the same on
every device and survives a backend restart, and `SessionConfig::started`
goes with the sort that needed it.

Rearranging is independent of the selection: the handle moves the row it is
on, picked out or not. The click moved off the card and onto its contents so
that a press landing on the handle cannot also select the row it is about to
move. Selection's one action is Delete, which now takes the whole set.

Two traps in `Reorder.kt`, both measured on the emulator and written down in
`this-machine-android`: a crossing is decided from how far the finger has
travelled, because a lazy list animates an item into its new place and its
`offset` reports the old one for several frames; and the viewport is pinned
with `requestScrollToItem` around each move, because a lazy list keeps its
place by the key of the top item and would otherwise follow the row being
dragged.

Verified on the emulator against the sandbox: the order survives an app
restart and a backend read-back, a two-row drag moves exactly two rows, a
drag to the bottom edge scrolls the list and lands the row last, pressing the
handle without moving changes nothing, and deleting two selected sessions
leaves the rest in place.
2026-09-20 01:06:15 -04:00
iris-aiandClaude Opus 5 942edd6b31 List a session's background tasks above its subagents
The count beside the status said how much work was going and never what,
so "3 bg tasks" was a number with no way to find out what it was about.

Drivers now report the tasks themselves rather than a size:
`Driver::background_tasks` returns `Vec<BackgroundTask>` -- id, the
provider's own description, and a kind -- served by
`GET /sessions/{id}/background`. It is runtime state, never persisted,
and `null` is "nobody has said", which is what a session with no process
answers and what the panel says in words rather than drawing as an empty
list. `description` is optional because Codex names a background terminal
by a process id, and a number drawn as a name is worse than admitting
there is none.

Claude's `background_tasks_changed` entries turn out to be objects
carrying `task_id`, `task_type` and `description`, so each is read rather
than counted -- and an `ambient` one is now dropped from the list and the
count alike, on the CLI's own instruction: a live-update watcher is not
activity, and counting one left a session reading `waiting` with nothing
to wait for.

The phone draws them in the right-hand panel above the subagents,
collapsed to "2 bg tasks running" and pushing the subagents down when
opened. Both lists are items of one lazy column, so neither can run off
the panel, and the section is refetched whenever the live count moves --
a card for work that has finished is exactly the stale measurement the
count exists not to be.

Verified against the real Claude CLI (2.1.261): a backgrounded `sleep 120`
came back as `{"id":"br16327wr","description":"Sleep for 120 seconds",
"kind":"command"}`, and on the emulator against the echo rig the section
appeared, expanded, and dropped a card as its task finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 23:29:40 -04:00
iris-ai c8bfc958ad Slide the main screen over a session with a right swipe
Switching conversation was a step back to the list and a step down into
another, which disposed the session being left and refetched its whole
transcript over the tunnel on the way back. A right swipe now pulls
MainScreen itself over the open session -- the screen Back would have
shown, moved over the session instead of replacing it -- and swiping it
back off returns to a live stream, an unsent draft and the scroll
position it had. Tapping the session already open is that same swipe
back; tapping another is a screen of its own; deleting the one
underneath closes the screen, since there is nothing left to return to.

One gesture drives both this and the subagent panel (SidePanels.kt, now
the home of the drag and animation SubagentPanel had): two draggables
over the same content cannot share a horizontal drag, so the position is
a single signed reveal, negative left and positive right, which also
makes it impossible to have both open. The panels exist only inside a
session, so nothing on the main screen swipes anywhere.

Full width and no tonal step for this one, because a screen standing in
for another must be the same colour as it; the subagent panel keeps its
88% and its sliver. The list keeps its rows while it asks again -- the
panel refetches on every open, and blanking it each time handed the
reader an empty screen about something never in doubt -- with a bar over
the top while an answer is outstanding.

Where the panel has got to is read from draw lambdas only: it changes
every frame of a drag, and a body that reads it recomposes the session
beneath once per frame. Composition sees booleans that change twice per
gesture, the same correction the keyboard inset needed.

Verified on the emulator against the sandbox with ui-trace: the panel
opens and closes on the two swipes, tapping another session replaces the
screen, deleting the open one leaves for the list, the subagent panel is
unchanged, and neither swipe does anything on the main screen. ktfmt,
compile, lint and the unit tests are clean.
2026-09-19 22:15:14 -04:00
iris-aiandClaude Opus 5 ef788b0405 Queue a llama message sent while its model loads
A message sent into a loading session was recorded as *read* the moment it
arrived: the phone drew it as sent, nothing read it for the next minute, and
the turn then folded the conversation out of a transcript that by then held
that same message and appended it again -- so the model was sent it twice.
It queues now, exactly as a message sent into a running turn does: drawn as
waiting, takeable back, and opening the first turn when the model arrives.
The conversation is read before the message is announced, which is what makes
"everything before this message" true rather than a race against the pump.
`await_ready` is left for the one case that still needs it, a turn whose model
was changed under it, and the `idle` that used to close a load is now decided
beside that first turn rather than racing it.

Two silent endings found while reproducing it, both of which look on the phone
like a message that was sent and never answered: an `{"error": ...}` chunk
arriving mid-stream on an otherwise successful response (the GPU out of memory
mid-decode), and a stream that stops without its `[DONE]` (the model unloaded
under the session). Neither is an ordinary end; the turn fails for both, and
keeps whatever arrived before it.

Ran against a real llama session on this VM's Qwen3-0.6B: a message sent
during the load now queues and is answered when the model lands, and
unloading the model mid-reply now says so instead of going quietly idle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:20:58 -04:00
iris-aiandClaude Opus 5 c3c6ab0ecf Steer a llama turn at its next tool boundary
A message typed into a running llama session waited for the turn to end and
then opened one of its own, so a turn spending minutes on a chain of tool
calls read nothing sent during it -- which is the one moment steering is for.
It now goes into the request the loop is about to build, prefixed with the
same note every other driver's steer carries.

The boundary being ours rather than the CLI's has two consequences worth
keeping: a waiting message can be taken back right up to the moment it is
read, and an interrupted turn deliberately takes nothing, since a request
that is not going out must not record a message as read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:30:35 -04:00
iris-aiandClaude Opus 5 81c30dcda1 Download a model onto the machine that will serve it
The Models tab was about this backend's own disk, which is the wrong disk
for every session that runs anywhere else: llama.cpp reads the file where
it runs. So the models of a machine live under that machine's llama.cpp
provider now, beside the settings deciding how each is loaded, and the
download that produces one happens there.

A download is a detached `curl` on that machine, started by a script this
server writes and never spoken to again. Its state is a file beside the
partial, so nothing about it is held here: it survives the app closing,
this backend restarting and a second device watching, and the progress is
`wc -c` of the partial against the size HuggingFace published rather than
anything remembered. A run whose process is gone is reported failed, since
`kill -0` is asked at each listing, and there is no "finished" state -- a
download that finished is a model, in the list beside the ones still
going. Resuming is guarded by the published sha256, which is also checked
before the file takes its real name.

Two other things the same screens wanted:

A provider is drawn as a card rather than as a line of text, bordered
against the machine card it sits in -- the tint it had was one step along
the surface ladder and rendered as one flat block -- with room to tap and
no chevron.

Nothing in a raw block wraps any more; the block scrolls sideways
instead, one offset for all its lines, so a diff or a column-aligned test
run still reads as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:55:51 -04:00
iris-ai 8c323fc7a9 Serve a machine's models from one shared llama-server
A llama.cpp session had its own `llama-server`: two sessions on one model
held two copies of it in memory, a model change bought a load only that
session benefited from, and the process was a session's to end. A machine's
models are now served by one `llama-server` in **router mode** -- no `-m`,
a preset file naming models and their flags, a child server per model asked
for, and each request routed by its `model` field. So one server per model
with that model's own settings is what a machine runs, while this backend
has one process, one port and one record per machine to keep track of.

The record is the mechanism every other driver already uses, so a restart
adopts it; a session records the same pid in its own directory as
`Detail::Shared`, and `process::signal` refuses to signal one of those --
which is what keeps stopping, deleting or cleaning up after one session
from unloading a model every other session is using. Nothing stops a router
on its own. That is deliberate (a loaded model is minutes of disk) and it is
why the machines tab now has a card per provider that opens its own screen:
how each model is loaded, how many stay in memory, Unload, and Stop.

How a model is *loaded* therefore belongs to the model on its machine rather
than to a session -- context size, GPU layers, threads, slots, speculative
decoding -- written into the preset as llama-server's own argument names.
Saving them re-reads that file, which unloads the model; that is the change
taking effect, and the dialog says so before you save. What stays a
session's is everything that rides on a request, including which tools it
offers: the router hosts one set for the machine and the choice is a filter
applied here, so it costs no reload (2,181 tokens of prompt with all seven,
698 with none).

Verified end to end against the scratch backend and the emulator: two
sessions sharing one loaded model with one child process, a second session
joining it with a 26ms prefill, a backend restart adopting the router and
answering with the prompt cache intact, the same over ssh to this VM, a
model's settings reaching the running server, Unload, and Stop leaving every
session `exited` with no error line.
2026-09-19 17:37:31 -04:00
iris-aiandClaude Opus 5 74cda485e5 Give a llama session a thinking level, asked of the model
A `thinking` param on the llama driver: "auto", "off", or a level, applied as a
chat-template argument on the next request -- `reasoning_effort`, or
`enable_thinking: false` for off -- so unlike the server flags it costs no
reload. It lands in the session settings dialog beside the other model
settings, which is what declaring it in `DriverKind::params` buys.

Which levels exist is the model's answer rather than a constant, because the
vocabularies disagree: the 27B here takes low, medium and xhigh and **raises**
on high and max, so a fixed list is a turn that fails on send. The driver asks
the loaded server (`thinking_options`) -- `chat_template_caps.
supports_reasoning_effort` for whether levels mean anything at all, which is
the gate that stops the control silently doing nothing on a template that
ignores the argument, then `/apply-template` per level, one cheap render each
at load time. Off is a separate argument and a separate question: honoured when
turning it off renders a different prompt, and both renders have to have
worked, since a template that refuses it also renders differently.

A level the loaded model cannot take is dropped from the request and said in
the transcript, naming what it does take. What is *not* said is anything about
a model nobody has asked yet: the answer is `Option<Vec<String>>`, where None
is "no server has been up" and an empty list is the model that genuinely takes
none.

Verified against the 27B on the GPU: "low" thought for 697ms and 79 characters,
"off" produced no thinking block at all, and "high" answered `this model does
not take "high" -- it takes off, low, medium, xhigh.` The picker wraps to two
rows in the settings dialog and shows the session's current value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:14:35 -04:00
iris-aiandClaude Opus 5 369b8f7e52 Report what a reply spent reading its prompt, and pin the clock right
`UsageDelta` gains `prefillMs`, llama-server's own `timings.prompt_ms`, so the
footer under a finished reply is "read 9.5s · 50.3 tok/s · 3:00 PM". Prefill is
the half of a turn that was invisible and is often the larger: measured on the
0.6B here, 1m 4s for the first turn after a model loads against 22ms for the
next, whose prompt the server still had cached.

The clock moves to the end of the line. Everything in front of it is a
provider's own measurement, so a session on another provider has fewer of them
or none, and a reader who has learned where the time is should not have to find
it again because the model changed. The costs grow leftwards into the space
instead, and a test asserts every shape of the line ends with the same thing.

Verified on the emulator against a real llama session: three replies reading
"read 1m 4s · 193 tok/s · 3:54 PM", "read 25ms · 308 tok/s · 3:54 PM" and
"read 22ms · 194 tok/s · 3:54 PM", with the clock in one column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:57:56 -04:00
iris-aiandClaude Opus 5 b660905098 Say which half of the wait a llama turn is in
A turn has two waits in front of the first token and they were one word.
`SessionStatus::Loading` was already the model coming off disk; this adds
`SessionStatus::Reading` for llama-server processing the prompt -- emitted when
the request goes out, cleared by the first thing the model says of any kind, so
it covers every generate in a tool loop rather than only the first.

Prefill is the expensive half on this machine: measured 9.5s for 6,068 tokens
and 22s for 14,068 on the 27B with the GPU to itself. Reported as `running`
that was indistinguishable from a model thinking, which is the thing the reader
is waiting for. The phone draws both with the working spinner and its own
words -- "loading model" and "reading prompt" -- and the session screen's
status row now spins for all three busy states instead of only `running`,
which is also how `loading` stops being a bare word with nothing moving.

Measured while checking the tok/s figure, and recorded in the rigs skill: the
27B holds 55.5 to 50.3 tok/s between 1.5k and 14k of context, so decode decays
gently, while the 0.6B on the CPU falls 30.1 to 11.5 over 6k. A shared GPU is a
different failure -- the model does not load at all.

Verified on the emulator against a real llama session: "loading model" while
the server started, then "reading prompt" with the spinner through prompt
processing, then the thinking card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:44:04 -04:00
iris-aiandClaude Opus 5 bb5ac1a242 Draw a model's thinking, and what a reply cost to produce
A llama.cpp session's `reasoning_content` becomes `Event::Thinking` deltas
closed by an `Event::ThinkingDone` carrying the span the driver measured, and
the phone draws it as a card of its own: "Thinking" with the spinner a running
command has, then "Thought for 12.4s". Deliberately not a tool call, so a run
of calls cannot collapse the reasoning into "Called 6 tools"; the reasoning is
also kept out of the next prompt, which `conversation` already ignored.

`UsageDelta` gains `tokensPerSecond`, the provider's own figure or nothing --
llama.cpp reports `timings.predicted_per_second` and the coding CLIs report no
such thing -- and a finished reply carries a small line under it saying when it
was sent and, where there is one, how fast it came out: "3:00 PM · 149 tok/s".

The compact usage bar drops the provider's name for the window and puts its
length after the time left instead: "42% · 3h 20m left / 5h".

Three things that had to come with it: the transcript coalesces runs of
thinking deltas as it does reply deltas, so one block is one row of a page
rather than a page of its own; `joinPages` welds a block cut by a page boundary
(`healSplitThinking`), since the half with no ending spun for ever; and
`UsageDelta` now reaches the fold, which is what carries the rate to the reply.

Verified on the emulator against a real Qwen3-0.6B session and the echo rig's
new `/think [seconds]`: the spinner while it runs, "Thought for 1.4s" and
"2:54 PM · 149 tok/s" after, the reasoning on tapping the card, and the usage
bar reading "42% · 3h 19m left / 5h".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:07:25 -04:00
iris-ai 45f249ae91 Even out the composer's row, and put its two pickers in settings
The composer's row gave the model and the permission mode whatever width
their words asked for, after three word-shaped action buttons had taken
theirs. A llama session's model names run long, so the permission mode
was squeezed to a chip too small to tap.

The three actions are circles now -- one diameter, the platform's minimum
touch target -- so they take what their glyph needs and nothing more, and
the two pickers share what is left evenly rather than by the length of
what they say. Every gap on the row is the same.

Both pickers are also rows in the session settings dialog, for every
provider that offers them: the dialog has a line each, so the whole model
name is readable there. Choosing goes through the same two functions as
the composer's pickers, so the model switch warning cannot be skipped by
picking from one of the two places -- and it closes the dialog rather than
stacking a question behind it.

Looked at on the emulator against the sandbox: a llama session with the
27B loaded, and a claude-cli one, both composer and dialog, with the
keyboard up and down.
2026-09-19 14:28:38 -04:00
iris-aiandClaude Opus 5 81ab564a09 Declare provider settings, and give the context figure a denominator
Two things a session could not say, and one it was saying wrongly.

**Every provider setting is reachable.** `-np 1`, the MTP draft depth, the
tool set, the sampling parameters -- most were hardcoded to what measured
best on this machine, which is right as a default and wrong as a constant:
the next machine has a different GPU and a different core count, and
nobody running this app can edit the source. `DriverKind::params` now
declares what a provider takes -- key, label, shape, what blank means, 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.
`POST /sessions/{id}/params` takes the whole map, so an absent key is the
instruction to unset; the sampling half applies at once and the session is
told in words which of the rest are waiting for a restart.

`tools` is one of them, because it is the biggest lever on a tight
context: the seven built-in definitions are ~1,300 tokens of every prompt
(2,191 against 887 with none). `"none"` omits the flag rather than passing
it on, since `--tools none` is `unknown tool "none"` and a server that
exits.

**The context figure has a denominator.** `Event::ContextWindow` carries
it, read from `llama-server`'s `/props` once the model is up -- the
measurement rather than the request, since a session that named no context
size gets the model's own. Neither coding CLI states its window, so those
keep the bare figure: "2,042" and "2,042 / 8,192" are deliberately
different-looking, and a missing ceiling is never drawn as a proportion of
an assumed one.

**And the numerator was wrong**, by the length of the last reply: it was
the prompt alone, so a five-word answer reported 2,042 against a slot
holding 2,355. It is the turn's total now, which matches `llama-server`'s
own `n_tokens` to within a token.

Two defects the review found, both of which would have shipped: changing
settings on a *stopped* session reported "no process running, so it can't
take new settings", when a stopped session is exactly when you would set
them for the next start; and `GET /tools` answers **403** rather than an
empty list on a server started without `--tools`, so reading it as a
failure made the no-tools session one that never started.

Verified against real models: settings spawned and changed live, the
restart note, a session with two tools and one with none, and the counter
checked against the server's own slot occupancy each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 13:58:08 -04:00
iris-aiandClaude Opus 5 ac476ab0c9 Give llama.cpp sessions tools, web search and a model picker
A llama session was a chat box: no tools, a fixed model, no permission
mode, and a model name drawn as the path the file sits at. It now runs the
agent loop itself, which is what the pieces below all hang off.

Tools are `llama-server`'s own (`--tools all`), which that server both
publishes and runs -- `GET /tools` for the definitions, `POST /tools` to
call one. Web search is Exa's MCP server, reached from this backend rather
than from the machine serving the model: that is what llama.cpp's own web
UI does, and it puts the search on the machine with a route out instead of
the one with the GPU. `llama-server`'s `--mcp-servers-json` can only spawn
local commands, so using it would have meant a Node bridge on every
machine that serves a model.

Driving the loop is what makes the permission gate ours. Two modes,
`manual` and `bypassPermissions`, which is what the mechanism has: the web
UI asks before every call and remembers the tools you say "always" to. The
allowances fold back out of the transcript's own answers, so they survive
a restart and a model change without being stored anywhere else.

Also here, because tools made each of them matter:

- **Loading is a state.** A 12 GB model takes twenty seconds to reach
  memory and refuses everything until it has; the session used to report
  `running` for that whole time, and a message sent meanwhile came back as
  an error. It is `loading` now, and the message waits.
- **The model can be changed.** A `llama-server` holds one model, so this
  stops it and starts another. The conversation survives because it was
  never in the server.
- **Models are named, not pathed.** `general.name` read out of the file
  itself -- over ssh too, in the round trip the spawn was already making.
  Where two models share a name the file name breaks the tie.
- **`-np 1`, and the MTP draft head where the file has one.** Measured on
  the 27B here: 41.5 tok/s plain, 61.4 with `--spec-type draft-mtp` at one
  slot, and 28 with it at four -- speculating against a split KV cache is
  worse than not speculating. The flag is conditional because asking for a
  head that is not there makes `llama-server` exit.
- **A refusal says what to do.** Tool results are thousands of tokens, so
  an overrun context is now ordinary; it was "http status: 400" and is now
  the server's own "exceeds the available context size, try increasing it".

`GET /machines/{id}/models` is gone: the provider models route answers the
same question, and two answers to one question is how a picker comes to
offer a model the spawn screen does not.

Verified end to end against real models: a tool call asked and allowed, an
Exa search, a shell command, a 27B loaded while a message waited on it, a
model switch mid-session, a second message queued behind a running turn,
and the whole of it again on a session running over ssh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:11:49 -04:00
iris-aiandClaude Opus 5 392cc5413d Recover Claude sessions with a stale resume token
A resume token the CLI will not accept made the session unrecoverable
rather than merely failed: every later start passed the same `--resume`,
died the same way, and nothing ever forgot it, so the chat could not be
opened again from the phone.

The reader now recognises both refusals the CLI gives, forgets the token
and emits `Cleared`, which is what the Codex path already does for a
thread whose rollout has gone. The transcript is this server's and
survives; only the model's context restarts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:56:41 -04:00
iris-ai 03376af446 Fix panel drag release flicker 2026-09-17 14:13:05 -04:00
iris-ai 0a2f0eed5f Move subagents into session side panel 2026-09-17 13:41:24 -04:00
iris-ai cd0229bed6 Show elapsed-time cursor on usage bars 2026-09-17 13:06:00 -04:00
iris-ai 84f978f16d Open a call from its foot upward, as a row already does
A closed call inside a group opened downward wherever it was pressed: the
anchor asked for the group's top in every case, which is right for a tap on the
call's heading and wrong for one at its foot, where what the reader wants held
is the edge under their finger. It is the rule every other row has had since
the anchoring went in, applied one level down.

An open in the call's lower half now asks for no scroll at all, which is the
same answer a row gets and for the same reason: the list holds the group's
bottom edge, a Column keeps the calls below the one growing at their distance
from it, so the growth comes off the call's top. The closing behaviour is
untouched -- the arithmetic is the same shift, written as the one term it
cancels down to, since where the call sits in the group and where the group
sits in the viewport drop out of it.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against a sandbox group of six calls, with enough
conversation behind it for the list to actually scroll -- a transcript shorter
than the viewport pins to the bottom and gives every anchor the same answer,
which is how this was missed. A call closed at 1321..1447: opened from 1430 it
leaves the call below it at 1485 to the pixel and takes the growth off its top;
opened from 1360 it leaves the calls above it where they are and moves the one
below down by the full 327; closed again from 1400 it lands at 1338..1464,
centred on the tap within a pixel.
2026-09-16 03:40:34 -04:00
iris-ai ee5bef3686 Centre a closed call on the tap, not the group around it
A call inside an open group was the one case still anchored by an edge: the
group held its top, which is right when a call is opened -- the heading under
the finger is the edge being pressed -- and wrong when one is shut by however
far down the open card the reader pressed. On a card of output that is most of
the screen, and what it looks like is the card collapsing into its own top, a
long way from the hand. It is the same rule as every other close now: what is
left of the call lands centred on the finger that shut it.

A call is not a row, so the scroll is still asked for against the group and
merely aimed at the call. What makes that possible is the group reporting how
far down its own top edge the call is drawn and how tall that card is, which is
the part only it knows; the calls above the one toggled do not move, so shifting
the group by the difference puts the call where the finger wants it.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against a real imported conversation: a call opened from its
heading inside a group of twelve leaves that heading where it is, and closing it
again from the middle of its output at 1800 lands the closed call at 1738..1860
-- centred on the tap to the pixel. The group's own close still centres on its
heading (1287..1413 for a tap at 1350).
2026-09-16 03:22:55 -04:00
iris-ai 914985b8b2 Ask for the scroll in the gesture, not from the layout
A correction made from the layout is a frame late whatever phase it is made in:
from placement it is never picked up by another measure and does nothing at all,
and from measure it lands on the next frame with the uncorrected one drawn
first. That is the flick when a card is opened, and it is why a close could
finish somewhere other than where it was aimed -- the two are the same fault.

Asked for at the tap instead, the request is consumed by the same measure pass
that first lays the row out at its new size, so the resize is drawn once, in its
right place. What makes that possible is that none of the three positions needs
to know the new height. A top edge holds by placing the item *above* the row
where it already is -- that item's bottom edge is the row's top edge whatever
becomes of the row, and it does not have to be composed for the list to place
it. A close places the row itself against the height it had at the moment it was
opened, which is the height it is going back to.

So the measure-phase hold, its modifier and the list's `afterMeasure` hook are
all gone, and this is 75 lines shorter than the version that could not do it.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against a real imported conversation as well as the sandbox:
a group closed by its heading lands centred on the tap (1467..1593 for a tap at
1530), a card closed at 1500 and at 1800 lands centred on each, opening by a
heading holds the heading to the pixel, and opening or closing a real call
inside a group of two leaves that group's heading exactly where it was.
2026-09-16 03:08:17 -04:00
iris-ai 581e07624f State where a resized row goes instead of walking it there
Correcting by the error each pass could see, and asking again on the pass that
answered, was a frame per pass with the ones in between drawn: opening a card
visibly stepped. It also still missed, because two of the passes were spent
finding out what the list would do rather than telling it.

`requestScrollToItem` against the row itself says it outright, in one pass: the
row is placed wherever it has ended up and whether or not it is still on screen,
and a negative offset -- which is the list being asked for the rows below a card
that has just given the screen its whole height back -- is exactly what a close
needs and works. Everything else follows from where that puts it.

A call opened or shut *inside* a group is a third case, and it was being treated
as the second: the group is not the thing opening, it is the container, and
centring it on the tap threw a group of six the length of the screen. What keeps
the call under the finger is holding the group's top edge, so that everything
above the change -- that call's own heading included -- stays where it is.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox with sampling as fast as the device will
report it, so an intermediate frame would show: a 2,785px card closed at 600,
1200 and 1800 lands centred on 600, 1200 and 1800 to the pixel, each in one
step; opening by the heading holds the heading still; and opening or closing a
call inside a group of five leaves the group's heading exactly where it was.
2026-09-16 02:53:51 -04:00
iris-ai 1b38579b97 Land a closed card centred on the tap that closed it
Two things were wrong with the hold, and each hid the other.

It held a *share* of the row's height: the point the finger was on stayed, in
proportion, which is the same miss in miniature as holding an edge. Tap away
from the middle of a long card and the heading landed most of a card's height
from the finger, and off it. A shut card is a heading, and the only place it
belongs is centred under the hand that shut it, wherever down the card the tap
was.

And the correction was worked out from the change in height, which needs the
list to behave the way the arithmetic assumed. It does not: which item it holds
still across a resize depends on what it has composed -- a row taller than the
screen is anchored on itself -- and a scroll it cannot honour in full is
honoured in part with nothing said. Measured rather than predicted now: each
measure pass asks for the error it can see, and the pass that answers is where
the rest becomes askable. Three passes is the worst seen, including the one
where a card that reached past the bottom of the screen has shut, left the
viewport entirely, and has to be asked back to the bottom edge before there is
anything to measure at all.

The pass has to be the *measure* one. A scroll asked for during placement is
never picked up by another measure and does nothing whatever -- which is what
the first version of this did, and why a close moved nothing -- so the list
took an `afterMeasure` hook and the correction lives there.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox, against a 2,785px card in a
conversation with room on both sides: closed at 1450 it lands 1387..1513, at
1700 it lands 1637..1763, at 1950 it lands 1887..2013 -- centred on the tap to
the pixel each time. Opening by the heading still holds the heading still, and
a group closed from its footer bar lands on the bar. Where the conversation
runs out -- a card at the very start with nothing above it to scroll -- it
lands as close as the list can put it, which is what it could always do.
2026-09-16 02:38:48 -04:00
iris-ai b86a5dc37a Hold an open call out of its run without taking one out of a group
Being open did two things to grouping, and only one of them was wanted. It held
a call standing on its own out of the run it belongs to, so a command finishing
behind the card being read no longer shuts it and folds it away mid-sentence.
It also took a call *out* of the group it was already inside, and that is what
made collapsing jump: grouping is what gives a row its identity, so one tap
rebuilt the rows around the finger -- opening a call inside a group split the
group into two pieces with mismatched keys, and closing one replaced three rows
with one, which no anchor survives. Measured at 450px of jump, with the card
that was closed going with it.

So the held-out set is now the screen's, not the transcript's: a call that has
never been drawn inside a group and is open stands out of its run, and a call
that has been in one stays in it whatever the reader does to it. Being inside a
group once is a fact about what the reader has been shown, which is why the
screen is what remembers it.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox: opening a call inside an open group of
six leaves it one group of six and closing it returns every row to the pixel it
came from; a call opened while standing alone survives a reply landing behind
it, and folds back into "Called 3 tools" when it is closed without moving the
rows below it.
2026-09-16 01:57:50 -04:00
iris-ai 463acb28fa Close a card on the point that was touched
Collapsing held one of the row's edges -- whichever the tap was nearer -- which
is right for opening and wrong for closing: the row that shuts leaves a heading
where a screenful of card was, and both its old edges can be a screen's length
from the finger that shut it. It now keeps the touched point itself, which for a
closed card is the same thing as landing under the hand that closed it. Opening
is unchanged and deliberately so: those rows are small, every point in them is
within a heading's height of both edges, and the edge pressed is what the reader
wants held rather than a fraction of an unbounded expansion.

One number carries both readings -- the share of the row's height above the
touch, spent as it is on a close and rounded to the nearer edge on an open.

The scroll offset the correction asks for goes negative on a close, and has to:
that is the list being asked for the rows below what it has composed, which is
where the newer content comes from when a card gives a screenful back. It was
clamped at zero, which was invisible while every correction was a row growing
and is what left closes uncorrected.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox: a 1441px card closed at a quarter of
its height put the collapsed card's top at 743px against 743 predicted, and
opening a card by its heading still holds the heading still.
2026-09-16 01:50:33 -04:00
iris-ai 827a30768c Count Codex background terminals 2026-09-16 01:07:40 -04:00
iris-aiandClaude Opus 5 cbae7ee8c0 Order the session list by when each agent was turned on
A running session no longer moves: the ones with a process come first,
oldest start first, so starting one appends it to the bottom of that
group and nothing it goes on to do -- beginning a turn, finishing one,
asking a question -- can shift it. Sorting by activity with the
awaiting-answer ones floated to the top is what this replaces; the
status word and its colour already say which session wants something
without the row having to move to say it. Stopped sessions are a group
below, most recently active first.

The order is the server's: `SessionConfig::started` is written each time
a process is started for a session and reported as `started`, so it is
the same on every device and survives a backend restart -- which adopts
processes rather than starting them, and so could not work the times out
for itself. Applied on the phone, because presentation order is a
display decision.

`LiveSession::info` takes the session's config entry rather than a
parameter per field read from it, which is what `AutoResumeView` existed
to bundle; that goes.

Verified on the emulator against the sandbox: three echo sessions kept
their order while the newest-active one was messaged; a stopped and
restarted session moved below one started after it; a stopped session
dropped below every running one; and after a backend restart the
recorded times came back unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:54:36 -04:00
iris-ai a9cfea89e5 Show Codex background task counts 2026-09-15 23:26:37 -04:00
iris-ai 947ea8ecf2 Keep tool group keys unique across transcript 2026-09-15 23:20:47 -04:00
iris-ai f00a178cf0 Ignore replayed tool starts 2026-09-15 23:14:11 -04:00
iris-ai 9bcf0f1a48 Keep open tool cards out of groups 2026-09-15 22:43:45 -04:00
iris-ai 33b130b6bb Let failed messages be discarded 2026-09-15 19:03:44 -04:00
iris-ai 3d1b1e304d Render whole-file patches from change metadata 2026-09-15 16:18:18 -04:00
iris-ai 06bf1c8f81 Preserve messages during Codex thread recovery 2026-09-15 15:30:22 -04:00
iris-ai 3f94eeb6d6 Create Codex threads on first message after clear 2026-09-15 14:43:23 -04:00
iris-ai 1c60e78b55 Keep Claude commands out of subagents 2026-09-15 14:29:18 -04:00
iris-ai 8262ceb786 Show live background task counts 2026-09-15 13:44:32 -04:00
iris-ai 9fd21af4e8 Reconcile Claude background task state 2026-09-15 12:49:22 -04:00
iris-ai f0661919bb Offer Claude sign-in from failed sessions 2026-09-15 12:24:25 -04:00
iris-ai 0be15adbee Post the drawer's row behind the app's own banner
A notification arriving while the app was open was shown as a banner and
nowhere else, so a moment that happened while the phone was face-up on a desk
left nothing behind at all -- the banner is seconds long and reaches only
somebody already looking.

The two are not two versions of one thing: a banner interrupts and a row
records. Both go up now, and the banner having done the interrupting is what
makes the row a silent one (`setSilent`), so one moment is worth a noise once.
What keeps the drawer from filling up is the other end rather than suppression,
and already was: opening a session clears whatever is posted about it, whichever
way the reader got there.

Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug,
and on the emulator against the sandbox, reading the posted record out of
dumpsys: app on the session list gives a banner and flags=AUTO_CANCEL|SILENT;
app backgrounded gives flags=AUTO_CANCEL; opening the session leaves nothing
posted about it in either case.
2026-09-15 02:06:12 -04:00
iris-ai 1e52b2910c Keep the last tool call outside its group once it finishes
A call left its group only while it was running, so the moment a command ended
it vanished behind "Called 3 tools" -- and a session that has run its last
command and is composing its answer, or has finished the turn entirely, spends
most of its time in exactly that state. 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.

Standing outside the run is the call's place in the list as it is now rather
than something recorded on the call, so it is asked of the list while grouping
it, where the rest of that decision already lives.

Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug,
and on the emulator against the sandbox: "/tools 3 1" settles as "Called 2
tools" with the third Bash card beneath it, and folds to "Called 3 tools" the
moment the next reply lands.
2026-09-15 01:52:51 -04:00
iris-ai 036eb375aa Keep the running tool call outside its group
A run of adjacent calls is drawn as one collapsed card, which hid the one
thing worth seeing without opening anything: the command the session is
running right now. It is a row of its own while it runs and folds back into
the run when it ends.

Grouping stays a display decision, so the pieces a running call cuts a run
into are keyed there. The first piece keeps the run's name -- that name is
what survives a page of history landing in front of it -- and later pieces
take their own first call's id behind it, since the call a run was named
after can itself be the one running.

The echo rig's /tools gap now runs between a call's start and its end rather
than between one call and the next, which is where a real session's time goes
and what makes the running state observable at all.

Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest (new
ToolRowsTest) and lintDebug, cargo fmt/clippy/test, and on the emulator
against the sandbox: "Called 2 tools" with the live Bash card beneath it.
2026-09-15 01:13:03 -04:00
iris-ai b9b777acaf Release messages after Codex recovery 2026-09-14 15:16:05 -04:00
iris-ai 46831520e3 Recover Codex sessions with missing rollouts 2026-09-14 15:01:47 -04:00
iris-aiandClaude Opus 5 3af2502982 Tell the model when a message was a steer
A message typed during a turn reaches the model at the next model call if
the turn has one left, and otherwise as the opening line of the next turn --
Claude's read out of the fifo after the turn ended, Codex's requeued when
turn/steer is refused. Read there it is indistinguishable from a reply, so
the model treats the answer it just gave as seen.

Both drivers now compose the text the CLI receives through
driver::message_body, which prefixes a note saying the message was written
without having seen the rest of that turn. The transcript still holds the
words that were typed; only the CLI's copy carries the note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:30:32 -04:00
iris 59ebd75b46 Route general lessons to the code-lessons skill
The routing note under "Things that have bitten" pointed at
~/.claude/TOOLCHAIN.md, which no longer exists -- its contents were folded
into the this-machine-* skills. Name the destinations that do exist, and add
the third case: a lesson that would bite any project anywhere now has a home
in the code-lessons skill rather than defaulting back to here.
2026-09-13 15:54:00 -04:00
iris 579689cbb8 Keep a thinking level the settings dialog set
The dialog held the level for as long as it was open and read it back
from the frozen row the session screen was opened with, so reopening it
showed the old level until a return to the list refetched the row. The
level is the session screen's own datum now, like the title.
2026-09-13 12:34:21 -04:00
iris fe25108c51 Count every Codex model request, not just the last
App-server sends `thread/tokenUsage/updated` once per *model request*, and a
Codex turn makes as many as it made tool calls. The translator held the last
one until `turn/completed`, so a turn's cost was reported as its final
request alone -- measured against the real rollout, 28,878 tokens for a turn
that spent 51,399 -- and the gap grows with how much work the turn did. The
context figure also stood still for the whole turn, which is exactly when it
is moving most.

Reported as each arrives instead: `tokens` now adds up to what the turn
spent, and the context figure climbs during the turn (28,921 -> 33,190 ->
35,978 on a two-file read here, matching Codex's own `last_token_usage`
exactly at every step).
2026-09-13 03:58:47 -04:00
iris 898e6b92d0 Clarify subagent coordination cards 2026-09-13 02:48:21 -04:00
iris cad0cbcfbe Keep subagent delivery out of assistant text 2026-09-13 01:05:35 -04:00
iris 83b113ef0f Support Codex subagent transcripts 2026-09-13 00:41:59 -04:00
iris 7d9df5d572 Rename setups and add provider reauthentication 2026-09-12 22:56:43 -04:00
iris e9a0f1b9da Do not enlarge images on open 2026-09-12 21:52:47 -04:00
iris 6d765ff6e4 Make image viewer truly full screen 2026-09-12 21:33:29 -04:00
iris 559e6c9226 Suppress errors for requested session stops 2026-09-12 20:49:38 -04:00
iris 76895bc644 Remove image viewer touch ripple 2026-09-12 20:28:28 -04:00
iris 0b4da64062 Fix image zoom focal point 2026-09-12 20:16:25 -04:00
iris 62cb6c91d5 Navigate explorer back toward project 2026-09-12 19:38:18 -04:00
iris 2ff0b13950 Return from files to explorer 2026-09-11 12:40:27 -04:00
iris 57e1cec09c Render Codex web searches as common tools 2026-09-11 02:31:21 -04:00
iris 6226a1cb43 Keep compact transcript history loading 2026-09-11 01:18:45 -04:00
iris 22f263ccce Restore directory navigation on Android back 2026-09-11 00:28:55 -04:00
iris 59965d314f Keep compact Codex history loading
Restart the history observer after every successful page so a collapsed tool page cannot consume the only layout invalidation that would request the next one.\n\nVerified with a cold 365-event tool-heavy sandbox transcript: without scrolling or expanding a group, cache coverage advanced continuously to sequence 1. Android format, compile, lint, and JVM tests pass. Transcript bench: 26 rows/26 units loaded, transcript draw 0.72 ms per frame (debug emulator).
2026-09-10 18:51:29 -04:00
iris e3cca97cdd Open transcript file links in explorer 2026-09-10 18:08:05 -04:00
iris 3c6e6778fd Keep sent messages visible until received 2026-09-10 02:17:11 -04:00
iris 3c19b5a9bb Fix Codex transcript convergence 2026-09-10 01:24:16 -04:00
iris f9c8f640ce Fix quoted Bash tool titles 2026-09-10 00:46:09 -04:00
iris 4c15150338 Return from files to explorer 2026-09-09 22:48:52 -04:00
iris cbdd8493ed Unwrap double-quoted Codex Bash commands 2026-09-09 22:17:14 -04:00
iris 26fe9895e7 Unwrap rendered Codex Bash commands 2026-09-09 22:11:52 -04:00
iris b00e89795e Parse Codex app-server patch payloads 2026-09-09 21:30:15 -04:00
iris 10ce1a216b Defer Codex patches until their diff arrives 2026-09-09 20:58:48 -04:00
iris b507656abd Normalize shell and patch tool cards 2026-09-09 20:30:52 -04:00
iris 4dc3e3d784 Fix Codex transcript streaming and images 2026-09-09 15:14:24 -04:00
iris 14dd520719 Fix explorer back and session usage selection 2026-09-09 13:01:27 -04:00
iris 8c88a7e991 Use native Codex steering and transcript deletion 2026-09-09 12:19:11 -04:00
iris 00538cc19b Show separate Codex usage pools 2026-09-08 00:41:06 -04:00
iris 7ee88dfd9c Make model and permission choices provider-specific 2026-09-08 00:08:09 -04:00
iris 6a0202b1b5 Add Codex JSON sessions and usage limits 2026-09-07 23:29:15 -04:00
irisandClaude Opus 5 0862b47f76 Record the loose end the taskNote outage exposed, and the last of its checks
A session whose transcript will not parse is skipped with only a log line, so
from the phone it is indistinguishable from an idle unresponsive one. That is
why the outage needed a report from Bryan rather than showing itself. The
cause is fixed; the class is not, and it is the "design the unknown state
first" rule rather than a bug in one code path.

Also rustfmt on the parse path, which the fix landed unformatted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:43:01 -04:00
irisandClaude Opus 5 fd71d876e1 Never let one unreadable line take a transcript down
Removing `Event::TaskNote` hours after adding it made every transcript that
had recorded one unreadable. `Transcript::open` parses every line, so `launch`
failed for those sessions and `SessionManager::new` logged
"couldn't relaunch session <id>" and skipped them -- and a skipped session has
no pump and no driver. On the phone that is no status, no history and nothing
sendable, for every live session that had run a background task. One
unfamiliar word took down every conversation it appeared in.

A transcript is append-only and permanent, so the set of kinds one can hold
only ever grows: what this build writes is not what it may have to read. A
line can come from a newer server, or from an older one that wrote a kind
since dropped, and neither may be able to end the file.

`Indexed::parse_at` degrades a line it cannot make sense of to
`Event::Unreadable { kind }` instead of failing the whole read. It keeps the
line's seq -- the cursors, the page bisection and the next-seq counter are all
addressed by it, and dropping the line would hand out a seq the file already
contains -- and carries the word the line called itself, so the phone can say
what is missing rather than that something is. A line with no readable seq is
still an error: that one cannot be placed at all.

`Event::TaskNote` comes back retired rather than deleted: deserializable,
never constructed, dated, with the reason on it. The phone folds it to no row,
which is the point -- an unreadable line correctly draws a placeholder, and
one per background task is the wall the row was removed for in the first
place.

Found while diagnosing a report that live sessions had lost their status and
could not be sent to. 173 server tests pass, including the new one, which
fails on the old code within a second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:23:45 -04:00
irisandClaude Opus 5 9cc52beb09 Report a backgrounded command into the card that launched it
A backgrounded command has no subagent, so there is no second transcript for
its report to live in and its own tool card is the only record of it anywhere
-- and until the task notification arrives that card is showing the launch
result, which says the command is running. It was left saying that for ever.

The report now updates the call's own row (`Event::ToolUpdate` against its
tool_use id), so the card ends up holding what became of the command instead
of a claim nothing was ever going to correct. That includes the endings that
carry no summary: those are exactly the ones that went wrong, and a stale
"running in background" reads worst on them, so they say the status word
rather than nothing. A task with a subagent behind it is untouched and its
report stays where it was, in that subagent's own transcript.

Echo grew `/background [seconds]` for the shape end to end: the Bash call, the
launch result, a turn that ends `waiting`, and the completion arriving later
to correct the card and start a second turn.

Verified on the emulator: the card reads `Background command "sleep 5 && echo
done" completed (exit code 0)` where it had said "Command running in background
with ID: ...". 172 server tests, ktfmt, clippy, rustfmt, Android lint and the
JVM unit tests clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:47:54 -04:00
irisandClaude Opus 5 1bbb642973 Take subagent reports out of the main transcript, and separate turns with a rule
A row per finished background task is a screenful of dividers about work the
reader was not asking after, and one of them turned out to be a whole shell
command drawn as centred prose, because its words came from somewhere with no
reason to keep them short. `Event::TaskNote` is gone entirely, along with the
row that drew it. A subagent's closing report is recorded as that subagent's
own transcript's closing text and is read in the subcard, which is where it
was already going; what the parent gets a row for is a message a subagent
genuinely sends it, which arrives by the peer path and has had one all along.

What remains is the actual defect and the smallest thing that fixes it. The
fold still refuses to grow a settled reply, so a turn boundary is always a
message boundary, and where two replies then abut it puts a `TurnBreak`
between them: a hairline, no words, no colour. 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. `joinPages` puts one in at a page seam,
which the fold never gets to see.

The task notification is still what closes a task in `Status::Waiting`'s
bookkeeping, and the registry lookup that recognises one this translator never
saw start is what makes that work for a session adopted across a restart.

Verified on the emulator: three replies, three rules, and nothing about the
helpers anywhere in the parent. 170 server tests, 85 JVM tests, ktfmt, clippy,
rustfmt and Android lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:39:19 -04:00
irisandClaude Opus 5 ef1aad8776 Keep a subagent's words in its own transcript, and count the ones already running
Two corrections to the previous commit.

A subagent's closing report belongs in the subagent's transcript, which is
where it already is; drawing it as a card in the parent's put the same
paragraph in two places for a reader who did not ask for it. The row is a
divider now -- a boundary, which is what the transcript actually needed there
-- closed, saying only what reported and how it went. Opening it shows the
report anyway, since leaving the conversation to read one line has its own
cost, and a backgrounded command has no transcript of its own so this is the
only place its report exists at all: that one names itself from its summary
and has nothing left to open. `TranscriptDivider` grew a `trailing` slot for
the chevron rather than the row growing its own copy of the rules.

And the status was wrong for a session that was already running before the
update, which is every session when the backend is replaced under it.
Adoption picks a session's stdout back up from a recorded offset, so the
`task_started` lines for subagents launched earlier are behind it and the
translator never saw them -- it started with an empty set and reported `idle`
with a subagent plainly still working. `Subagents::any_open` reads the
directory instead, which is a measurement rather than bookkeeping and is right
for a session this process did not start. Both sources are kept and neither
subsumes the other: the translator's own set is the only thing that knows
about a backgrounded *command*, which has no subagent to be found. The same
pair decides whether an ending has already been reported, so a task that began
before the restart still gets its divider.

Echo's helpers now record their report as their own subagent's closing text,
the way the real driver does, so the fixture has the shape being tested.

Verified on the emulator: three dividers closed, one opened to its report, and
each reply drawn as its own message. 170 server tests, ktfmt, clippy, rustfmt,
Android lint and the JVM unit tests all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:54:36 -04:00
irisandClaude Opus 5 5711c2568a Never run two turns into one, and say when a session waits on its own work
A turn started by something with no row of its own -- a subagent reporting
back, a peer message the CLI only owns up to at the end -- met the previous
reply with nothing between it, and the fold grew that reply rather than
starting a new one. Two answers were drawn as one paragraph, running together
mid-sentence with not even a space between them. The fold now refuses to grow
a settled reply, and `joinPages` carries the same rule across a page boundary.

The other half is the row. `Event::TaskNote` records a background task
reporting back -- a subagent that finished, or a backgrounded command -- with
its title, how it ended and what it said; `TaskNoteRow` draws it as a card,
since somebody said this, and its own row rather than an update to the Task
call's, which is above everything the session has said since. Reported once
however many of the CLI's two lifecycle shapes arrive.

`SessionStatus::Waiting` is a session whose own turn is over while work it
started is not. `Idle` means "waiting for a person" and this means the
opposite, so reporting it as idle sent a "finished" notification at the one
moment that was untrue. Drawn as "waiting" in `waitingColor`; the queue and
the held-command boundary release on either end-of-turn status, so a message
sent while a subagent runs is not held until it finishes.

And a usage limit the account hits inside a subagent now reaches the session
as well as the subagent's transcript. `resume.rs` can only schedule against a
session, and a background Task outliving its parent's turn is the ordinary
case, so auto-resume was doing nothing at all for it.

The status word and its colour were two `when`s on two screens, and the second
missed `waiting` silently; they are `sessionStatusWord`/`sessionStatusColour`
now. Echo's `/subagent n` reproduces the whole shape, staggered a second
apart. Verified on the emulator against the sandbox: 169 server tests, ktfmt,
clippy, rustfmt, Android lint and the JVM unit tests all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:57:30 -04:00
irisandClaude Opus 5 74c07d687a End subagents on the CLI's own task lifecycle, and detect a limit two ways
Subagents were showing "running" long after they had finished. Measured
against 2.1.237 by running a session that launched one Task agent and
reading its stdout: a subagent's lines carry no `stream_event` at all --
they are whole `user`/`assistant` lines with a null `stop_reason` -- and no
`result` line is sent for one. So `ends_a_turn`, which watches for a raw
`message_delta` saying `end_turn`, could never fire for a subagent, and
nothing finished one until its session's process exited.

What the CLI does send is a task lifecycle, as top-level `system` lines:
`task_started` (with the tool_use id), `task_progress`, `task_updated`
(status, naming the task only) and `task_notification` (tool id, status, and
the agent's own summary). `translate_task` keeps the task -> tool mapping,
records the summary as the subagent's closing text -- the run showed its
child lines stop at its last tool_result, so without this a finished
subagent reads as stopping mid-tool -- and ends it. A `completed` update is
deliberately not the end, since its notification carries the summary; any
other terminal status is, because the failure to avoid is a subagent nothing
ever finishes. `ends_a_turn` stays as a second detector and must never be
the only one again. Verified by replaying the captured stream through the
server as a fake CLI: running, prompt, Bash call, output, report, exited.

`finish_all` now reads the directory rather than the live map, which is what
clears the ones already stuck: a subagent left running by an earlier run of
the server is exactly the one this process never touched, so it read
"running" again every time its session was started.

Auto-resume gets the same treatment on its own single point of failure. The
only thing that scheduled a resume was the CLI's error sentence at the end
of a failed turn; the CLI also sends `rate_limit_event` lines saying where
the account stands, and this server ignored them entirely. Both are read
now. Anything that is not an `allowed...` status counts as refused and is
logged if unfamiliar -- being wrong that way costs one question to the usage
meter, which is still what decides whether anything is sent, and being wrong
the other way is the feature silently not existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:29:23 -04:00
irisandClaude Opus 5 13d2d11c2d Order a session's subagents by activity, and delete finished ones by holding
The subcards were oldest first, which buried whatever is working now. They
are ordered on the phone -- still running first, then most recently active --
over the server's stable oldest-first answer, since presentation order is a
display decision and a subagent that is thinking reports nothing meanwhile.

Holding a subcard selects it and several at a time, the import list's gesture
and its confirmation, so selecting is learned once. The selection bar sits
inside the session's card rather than at the bottom of the screen: it belongs
to one card, and one Delete is one request against one parent, so picking a
row in another card moves the selection rather than adding to it. Delete is
disabled, with the reason in words, while anything selected is still running
-- its transcript is still being written to and its process is the session's
to stop, so the server refuses that batch outright.

`POST /sessions/{id}/subagents/delete` takes the batch and checks every id
before removing any, so a set naming a running one is left exactly as it was
rather than half-deleted. It is `Subagents::start`'s path out. What counts as
running is shared with the list route through `has_a_process`, so the two
cannot disagree. On success the phone takes those rows out of that one card
and off the session's count, purges its cached copies, and drops the
expansion when nothing is left -- nothing else is refetched.

Driven on the emulator against the sandbox with ui-trace's new hold-by-name:
selecting two, the dialog, the rows going, a running one holding Delete
disabled, and the expander leaving with the last subagent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:10:21 -04:00
irisandClaude Fable 5.1 cf10b17c5b End a subagent on its own end_turn, not the parent's tool_result, and give the expander a touch-sized row
The Agent tool runs subagents in the background, so the parent's result
arrives at launch while the subagent works on for minutes; finishing on it
read a running agent as finished with a transcript cut off at launch. A
subagent now ends on its own message_delta end_turn, and a later line for a
finished one reopens it, since a background agent can be messaged again.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:45:43 -04:00
146 changed files with 37423 additions and 13504 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
../../.claude/skills/ai-app-rigs
+380
View File
@@ -0,0 +1,380 @@
---
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.
+677 -913
View File
File diff suppressed because it is too large. Load diff
+44
View File
@@ -0,0 +1,44 @@
# Decisions awaiting review
Choices made while working autonomously, for Bryan to keep or change. Each
says what was picked and why; the detail is in the design doc it names.
Delete an entry once it has been looked at.
## Subagent views (2026-09-05, `SUBAGENTS.md`)
Made on my own judgement, limited blast radius:
1. **A subagent is a transcript, not a session.** It has no process,
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
and stored under the session's directory, so deleting the session takes
it. Alternative rejected: registering it as a session of its own, which
would give it a card in the main list and a driver that can do nothing.
2. **Read-only view is the session screen minus its controls**, rather than
a second, simpler transcript screen. Keeps paging, caching, selection
and rendering in one place. Cost: a `readOnly` mode threaded through
`SessionScreen`.
3. **The list only carries a count.** Each session row says how many
subagents it has; their titles and statuses are fetched when the card is
expanded. Keeps `GET /sessions` from reading every subagent transcript.
Consequence: an expanded card's statuses refresh with the list, not live.
4. **Expanded/collapsed is remembered per session on the phone**, not on
the server. Collapsed by default, per the transcript convention that new
things arrive collapsed.
5. **Subagents of imported sessions are not shown.** The import path still
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
are not read. Only subagents run while this backend was watching exist.
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
paid turn to exercise.
Deferred, because they reach further than this feature:
- **Live status on the list.** Whether the session list should follow a
stream at all (it refreshes on demand today) decides whether subagent
status can ever be live there. Not changed.
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
in its transcript and are not given transcripts of their own. Supporting
that is the same mechanism one level down, but the UI would need nested
expanders.
- **The subagent status row says "context unknown".** Nothing measures a
subagent's context; the row could leave it out rather than admit it.
+253 -377
View File
@@ -1,447 +1,327 @@
# The file explorer
Asked for by Bryan on 2026-09-03: replace the session screen's debug
button with a folder icon that opens a file and directory viewer for the
machine the session runs on. Browse directories, open files with the
existing syntax highlighting, line numbers, no wrapping; edit a file behind
a pencil icon; create files through a modal like the ones the app already
has; work over ssh; open at the session's working directory.
Asked for by Bryan on 2026-09-03 and built the same day: browse a machine's
directories, open files with the existing syntax highlighting and line
numbers, edit behind a pencil, create through a modal, work over ssh, and
open at the session's working directory.
Built on 2026-09-03. This is the design, decision by decision with the
reason and what was rejected, so that when one changes it is changed here
rather than re-argued. The operational half -- how to run it, what to press,
what to produce on purpose -- is in AGENTS.md, where the rest of this
project's working notes are.
This is the design, decision by decision with the reason and what was
rejected, so that when one changes it is changed here rather than re-argued.
The operational half how to run it and what to produce on purpose — is in
AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
`FileViewer.kt` / `FileEditor.kt` / `FileLines.kt` are the app.
## What it is, in one paragraph
A machine's filesystem, seen from the phone through the backend. The
explorer belongs to a **setup** (a machine), not to a session: a session
only says where to start. Every operation -- list, read, write, create --
is one shell script run through `Transport`, exactly the way the import
listing and the usage fetch already work, so the local and the ssh case
are one implementation and a machine the backend cannot reach fails with
ssh's own message. The phone draws what came back: a listing, a file with
its lines coloured by the scanner in `Highlighter.kt`, or an editor over
the same text.
A machine's filesystem, seen from the phone through the backend. The explorer
belongs to a **machine** (a machine), not to a session: a session only says
where to start. Every operation list, read, write, create — is one shell
script run through `Transport`, exactly the way the import listing and the
usage fetch already work, so the local and the ssh case are one
implementation and a machine the backend cannot reach fails with ssh's own
message. The phone draws what came back.
## Decisions
### 1. Keyed on the machine, opened from the session
Routes live under `/setups/{id}/…`, beside `importable`, because a
filesystem is a property of a machine. The session screen's folder button
opens the explorer with the session's setup and its `cwd` as the starting
directory; a session with no `cwd` opens at the machine's home, which the
machine resolves (`cd` with no argument and `pwd -P`), never a path the
phone guessed. Nothing in the explorer knows what a session is, so a later
entry point from the setups tab is one more caller and no new code.
Routes live under `/machines/{id}/…`, beside `importable`, because a filesystem
is a property of a machine. The session screen's folder button opens the
explorer with the session's machine and its `cwd`; a session with no `cwd`
opens at the machine's home, which the **machine** resolves (`cd` with no
argument and `pwd -P`), never a path the phone guessed. Nothing in the
explorer knows what a session is, so a later entry point from the machines tab
is one more caller and no new code.
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
find the setup, and "browse this machine" from anywhere but a session would
need a session to exist first.
find the machine, and "browse this machine" from anywhere else would need a
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
Each operation is a small POSIX shell script handed to `sh -c script sh
"$path" …` through `Transport::capture` (or the stdin-carrying variant
below). The path and every other value cross as **positional arguments**,
never interpolated into the script -- the same rule `import::find` follows
with `"$1"`, and the same reason `ssh::quote` exists: a path is
attacker-adjacent input in a server whose job is running commands. A `~`
prefix is handled by the same `quote_path`/`expand_home` pair every other
path goes through; nothing new is invented for it.
Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
through `Transport::capture` (or `capture_with_input`). The path and every
other value cross as **positional arguments**, never interpolated into the
script the same rule `import::find` follows and the same reason
`ssh::quote` exists: a path is attacker-adjacent input in a server whose job
is running commands. `PATH_PRELUDE` is the one line that gives a leading `~`
its meaning, since a shell expands a tilde in text and not in an argument.
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
`sha256sum`, `chmod --reference`). That is already what `import.rs`
assumes (`stat -c`, `/proc`), and both machines that exist are Linux. A
machine without them fails with that tool's own message, which names what
is missing.
`sha256sum`, `chmod --reference`) already what `import.rs` assumes, and
both machines that exist are Linux. A machine without them fails with that
tool's own message, which names what is missing.
Rejected: `std::fs` for the local transport and scripts for ssh. Two
implementations of "list a directory" drift -- the ordering of entries,
what a symlink reports, how a permission error reads -- and the local one
is the one that gets tested, so the remote one ships broken. The transport
design exists so that a driver never learns which machine it got; the
explorer is held to the same rule. The cost is a `sh` process per
operation locally, which is under a millisecond.
implementations of "list a directory" drift the ordering of entries, what a
symlink reports, how a permission error reads and the local one is the one
that gets tested, so the remote one ships broken. The cost is an `sh` process
per operation locally, which is under a millisecond.
Rejected: a Rust SSH or SFTP library. PLAN.md rule 23 -- the system `ssh`
inherits `~/.ssh/config`, agents and jump hosts, and there is one place to
configure a connection. SFTP would need a second one.
Rejected: a Rust SSH or SFTP library. The system `ssh` inherits
`~/.ssh/config`, agents and jump hosts, and there is one place to configure a
connection; SFTP would need a second.
### 3. The token can now name a path, and that is written down
AGENTS.md says of the import route: "the phone picks an **id**, never a
path: the server resolves which file that is, so an enrolled token cannot
become 'read me an arbitrary file'." The explorer's whole purpose is the
path, so it takes one. This is recorded in PLAN.md's Security section as a
change to the threat model paragraph, in these terms: the token already
gates spawning a bypass-permissions agent in any directory on any machine
a setup names, and that agent can already read and write every file its
user can. The explorer is a shorter path to authority the token already
holds, not new authority. The import route's rule stands where it is,
because there a path was unnecessary and refusing it cost nothing.
Elsewhere the phone picks an **id** and the server resolves which file it
names, so an enrolled token cannot become "read me an arbitrary file". The
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
Security section in these terms: the token already gates spawning a
bypass-permissions agent in any directory on any configured machine, and
that agent can already read and write every file its user can. The explorer
is a shorter path to authority the token already holds, not new authority.
The import rule stands where it is, because there a path was unnecessary and
refusing it cost nothing.
What is *not* changed: no route accepts a command. Listing, reading and
What is *not* changed: **no route accepts a command.** Listing, reading and
writing are fixed scripts; the phone chooses only the path and the bytes.
### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one
Same rule as `POST /sessions/{id}/cwd`: a relative path is refused with
the same wording, because where it would be depends on where nothing the
reader can see. Every listing answers with `pwd -P` of the directory it
listed, so the phone navigates on a resolved absolute path -- the parent
of `/home/bob/repos/ai-app` is a string operation on that, and a `~` the
session was spawned with is shown as what it turned out to be. The phone
never resolves `..` itself.
Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
a relative path would be depends on something the reader cannot see. Every
listing answers with `pwd -P` of the directory it listed, so the phone
navigates on a resolved absolute path. The phone also resolves `~` through the
same route, then shortens that directory and every path beneath it back to
tilde notation for display; it never guesses where a local or ssh user's home
is. The phone never resolves `..` itself.
### 5. A read is capped and typed, and every state it can be in has a word
`GET /setups/{id}/file` answers with one of:
- `text` -- the content, with its size, mtime and sha256.
- `binary` -- the content is not UTF-8. Size reported, nothing shown.
- `tooBig` -- over `FILE_LIMIT` (1 MiB to start; see "Numbers to
measure"). Size reported so the reader knows what they are looking at.
- an error -- no such file, permission denied, machine unreachable --
carrying the machine's message.
`GET /machines/{id}/file` answers with one of `text` (content, size, mtime,
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
at), or the machine's own error.
Four outcomes rather than content-or-error, because a binary file drawn as
text and a big file cut off silently are both wrong in ways the reader
cannot see, and "couldn't read it" must not look like "it is empty". An
empty file is `text` with empty content and is drawn as one empty line
numbered 1, which is what it is.
Not in the first cut: showing images (the phone has `isImageRef` and a
viewer already; the route would serve bytes). Listed under "later".
text and a big file cut off silently are both wrong in ways the reader cannot
see, and "couldn't read it" must not look like "it is empty". An empty file
is `text` with empty content, drawn as one empty line numbered 1, which is
what it is.
### 6. A write is conditional on what the reader saw
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
compares it against the file as it is now and refuses with a distinct exit
code if it differs; the server answers **409** with "changed on the machine
since you opened it". Agents edit files while people read them; this is
the common case, not the exotic one, and silently overwriting an agent's
edit with a stale copy is the worst available outcome. The phone offers
three ways out and says what each costs: **Overwrite** (theirs is lost),
**Reload** (yours is lost), **Cancel** (keep editing, decide later).
`PUT /machines/{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;
the server answers **409**. Agents edit files while people read them; this is
the common case, not the exotic one, and silently overwriting an agent's edit
with a stale copy is the worst available outcome. The phone offers three ways
out and says what each costs: **Overwrite** (theirs is lost), **Reload**
(yours is lost), **Cancel** (keep editing).
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1"
"$1.ai-app-tmp" && mv -f -- "$1.ai-app-tmp" "$1"`, with the bytes on
stdin. A temp file and a rename, so a connection dropped mid-write leaves
the old file whole rather than a truncated one; `chmod --reference` keeps
the mode, which a fresh file would otherwise lose (an executable script
would stop being one). What this trades away: the inode changes, so a hard
link elsewhere stops being the same file. Accepted; editors do the same.
The check-then-write is not atomic against a writer landing between the
two -- a window of microseconds on the same machine -- and that is accepted
too, and noted at the script.
The response carries the new size, mtime and sha256, so the editor's
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1" … && mv -f`,
with the bytes on stdin: a temp file and a rename, so a connection dropped
mid-write leaves the old file whole rather than truncated, and
`chmod --reference` keeps the mode a fresh file would lose (an executable
script would stop being one). What this trades away is the inode, so a hard
link elsewhere stops being the same file — accepted; editors do the same. The
check-then-write is not atomic against a writer landing between the two, a
window of microseconds on the same machine; accepted, and noted at the
script. The response carries the new size, mtime and sha256, so the editor's
precondition is fresh without a second read.
### 7. Create refuses to overwrite
`POST /setups/{id}/file {path}` runs under `set -C` (noclobber) and
`: > "$1"`, so a name that exists fails with the shell's own message rather
than truncating somebody's file. `POST /setups/{id}/dir {path}` is `mkdir
--` with the same property. The modal names one thing in the current
directory and has a switch for "directory"; a created file opens straight
into edit mode, because an empty file is not something to look at.
`POST /machines/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
name that exists fails with the shell's own message rather than truncating
somebody's file; `POST /machines/{id}/dir` is `mkdir --` with the same
property. The modal names one thing in the current directory and has a switch
for "directory"; a created file opens straight into edit mode, because an
empty file is not something to look at.
Rejected: create-with-content in one request. The editor is the place
content is typed, and a modal with a text area is a second editor.
Rejected: create-with-content in one request. The editor is where content is
typed, and a modal with a text area is a second editor.
### 8. The viewer is a list of lines, coloured once
The file is scanned once, off the main thread, by `scan` in
`Highlighter.kt` with `rulesOf(language)`; the spans are bucketed per line
in one pass, and each line's `AnnotatedString` is built when that line is
composed. A `LazyColumn` of lines, not one `Text`: text layout is linear
in the text, and a 20,000-line file in one `Text` measures all of it to
draw a screenful. Lines are drawn with `softWrap = false` inside one
shared `horizontalScroll` state, so the whole file scrolls sideways as a
block and a line never wraps.
The file is scanned once, **off the main thread**, by `scan` in
`Highlighter.kt`; the spans are bucketed per line in one pass and each line's
`AnnotatedString` is built when that line is composed. A `LazyColumn` of
lines, not one `Text`: text layout is linear in the text, so a 20,000-line
file in one `Text` measures all of it to draw a screenful.
**Sharing that state is not enough on its own, and this is where it was
wrong.** `horizontalScroll` is a node per row, and each one coerces the
shared offset into *its own* range -- content width less viewport -- so
with rows at their natural widths a short line's range is zero and it does
not move at all while the long line beside it does. Each row also writes
**Every row is given the same width**, and that is what makes the shared
horizontal scroll work. `horizontalScroll` is a node per row, and each one
coerces the shared offset into *its own* range content width less viewport
— so with rows at their natural widths a short line's range is zero and it
does not move at all while the long line beside it does. Each row also writes
`maxValue` as it measures, so how far the file could be dragged was decided
by whichever row measured last, and changed as the list scrolled. Both go
away once **every row is given the same width**: the longest line in
columns times one character's advance, which is arithmetic rather than
twenty thousand measurements because the face is monospace. A tab counts as
eight columns and deliberately upwards -- over-estimating leaves a little
empty space past the longest line, under-estimating puts the end of that
line out of reach -- and the width is capped well under what `Constraints`
can carry, so a minified file is a scroll that stops early rather than a
crash. Reported by Iris on 2026-09-04 as "it seems to affect different rows
differently", which is precisely what a per-row range looks like.
by whichever row measured last and changed as the list scrolled. The width is
the longest line in columns times one character's advance, which is
arithmetic rather than twenty thousand measurements because the face is
monospace. A tab counts as eight columns and deliberately upwards —
over-estimating leaves a little empty space past the longest line,
under-estimating puts the end of that line out of reach — and the width is
capped well under what `Constraints` can carry, so a minified file is a
scroll that stops early rather than a crash. Reported by Iris on 2026-09-04
as "it seems to affect different rows differently", which is precisely what a
per-row range looks like.
**The stretch at the ends is one effect too**, shared by every row and
rendered once on the box around the list -- `horizontalScroll` makes its
own per node otherwise, so only the line under the finger bent and the
rest of the file sat still beside it. That is the same complaint one layer
further out, and it is only fixable now that every row agrees where the
end is. It cannot be seen from this VM: the emulator's screenshots come
back with no stretch in them at all, for any scrollable, so this one is
checked on the phone.
rendered once on the box around the list `horizontalScroll` makes its own
per node otherwise, so only the line under the finger bent while the rest of
the file sat still. It cannot be seen from this VM: the emulator's
screenshots come back with no stretch in them at all, for any scrollable, so
that one is checked on the phone.
**The numbers sit outside that box**, so they neither travel with the text
nor bend with it. The rows leave a spacer where the numbers go and a
`SubcomposeLayout` beside the list draws them. That is the one arrangement
that keeps them level: which numbers exist *and* where each goes both come
from the list's own `layoutInfo`, read in the measure block, and
subcomposition happens during measurement -- so it composes from the answer
the list has just produced rather than from one it read a frame ago. A
column translated by the scroll position could not, since the translation
would be current while the set of numbers was a composition behind, and
during a fling the numbers would slide against their lines. Checked at
about 1kHz through a fling: 23,520 row observations over 552 frames, every
one of them with its number at exactly its own top.
subcomposition happens during measurement so it composes from the answer
the list has just produced rather than one it read a frame ago. A column
translated by the scroll position could not, since the translation would be
current while the set of numbers was a composition behind, and during a fling
the numbers would slide against their lines. Checked at about 1kHz through a
fling: 23,520 row observations over 552 frames, every one with its number at
exactly its own top. A consequence worth having: the numbers are outside the
`SelectionContainer`, so copying part of a file gives the code rather than
the code with a number in front of every line.
A consequence worth having: the numbers are no longer inside the
`SelectionContainer`, so selecting part of a file and copying it gives the
code rather than the code with a number in front of every line.
Line numbers are a gutter in each row, right-aligned, with the gutter
width taken from the digit count of the line count in the same monospace
style -- so a 9-line file and a 12,000-line file each get exactly the
width they need and nothing is measured by hand. Because nothing wraps, a
logical line is one visual line, and the gutter cannot drift from the text
it numbers. Gutter numbers take `onSurfaceVariant`; the text takes the
The gutter is right-aligned, its width taken from the digit count of the line
count in the same monospace style, so a 9-line file and a 12,000-line file
each get exactly the width they need and nothing is measured by hand. Because
nothing wraps, a logical line is one visual line and the gutter cannot drift
from the text it numbers. Numbers take `onSurfaceVariant`; the text takes the
scanner's palette on `rawSurface`, the surface every verbatim thing in the
app already sits on.
The language comes from the file's extension through the same table
`fenceLanguage` reads (`FENCE_LANGUAGES` already keys on `kt`, `rs`,
`py`, …). One function, `fileLanguage(name)`, takes the part after the
last dot and asks that table; it is one table, not two, so a language
added for fences is added for files. A file with no entry is drawn plain,
for the reason the table's comment gives.
Selection: the lines sit inside one `SelectionContainer`, as the
transcript does, so a selection can run across lines.
`fenceLanguage` reads — one table, not two, so a language added for fences is
added for files. A file with no entry is drawn plain.
### 9. The editor is the legacy text field with a highlighting transformation
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the
same monospace style, inside the same horizontal scroll so it does not
wrap, with a `VisualTransformation` that returns the text unchanged and
the scanner's spans as styles (`OffsetMapping.Identity`, since no
character moves). This is the one Compose API that colours a field's text
without replacing the field; the newer `TextFieldState` API has no hook
for styles. The gutter is one `Text` of `1\n2\n…` in the same style beside
the field, aligned for the same reason as the viewer: no wrap, one line
each.
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the same
monospace style, inside the same horizontal scroll so it does not wrap, with
a `VisualTransformation` that returns the text unchanged and the scanner's
spans as styles (`OffsetMapping.Identity`, since no character moves). This is
the one Compose API that colours a field's text without replacing the field;
the newer `TextFieldState` API has no hook for styles. The gutter is one
`Text` of `1\n2\n…` beside the field, aligned for the same reason as the
viewer.
Save is a glyph in the header, **disabled** until the text differs from
what was loaded (never hidden -- a control that comes and goes makes its
own absence the signal), and a `GlyphSpinner` while the write is out.
Back with unsaved changes asks; the question says the edits will be lost.
The keyboard: the explorer draws over the session, which deliberately has
no `imePadding` (see `SessionScreen`'s layout note), so the explorer's own
box adds it.
Save is a glyph in the header, **disabled** until the text differs from what
was loaded never hidden, since a control that comes and goes makes its own
absence the signal. Back with unsaved changes asks, and says the edits will
be lost. The explorer draws over the session, which deliberately has no
`imePadding`, so the explorer's own box adds it.
Re-scanning on every keystroke is the cost to watch. For a file under
`FILE_LIMIT` it is expected to be a few milliseconds (the scanner replaced
a library that took 174ms on 200 lines; ours has not been measured on a
1 MiB file). Measure before deciding whether edit mode needs a size below
which highlighting is on -- see "Numbers to measure".
### 10. The explorer draws over the session, and back closes it first
### 10. The explorer draws over the session, and back follows what is open
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
`FilesScreen` is composed **on top of** the session in the same `Box`, and
the session stays composed under it: its event stream keeps flowing, its
scroll position and draft stay where they were, and returning from a file
costs nothing. Back -- the button and the platform gesture --
clears `files` when it is set and goes to the list otherwise. Inside the
explorer the same back steps one level: editor → viewer (with the unsaved
question), viewer → listing, listing → parent directory it came from, and
only from the starting directory does it close. "Back returns; it does not
exit."
costs nothing. From an open file, both the header's back button and Android back
return to its containing directory. From a directory, the header's back button
clears `files` and returns to the session. Android back instead walks toward the
session's project directory: upward to the common ancestor, then down one path
segment per press, and at the project it returns to the session. This makes
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 leaf screen goes to Main today, and a session disposed and re-created on
each return refetches its transcript over the tunnel -- exactly the flip
between "what did it change" and "what is it saying" this feature is for.
The image viewer already made the same choice for the same reason.
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
leaf screen goes to Main today, and a session disposed and re-created on each
return refetches its transcript over the tunnel exactly the flip between
"what did it change" and "what is it saying" this feature is for. The image
viewer already made the same choice for the same reason.
### 11. The listing is drawn as it came, sorted at display time
Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and
whether the entry is a symlink (with the kind being the *target's*, from
`find -printf '%Y'`, so a link to a directory navigates). Sorted on the
phone, stably: directories first, then case-insensitive name. Dotfiles are
shown -- in a repository they are half of what matters. A row is the
glyph, the name, and the size for a file; tapping a directory descends,
tapping a file opens it. Each directory's entries are kept for as long as
the explorer is open, keyed by path, so returning to one does not refetch
it; the header's refresh glyph refetches the current one on purpose, and a
create refetches the directory it created into, since that is what the
operation changed.
whether the entry is a symlink with the kind being the *target's*, from
`find -printf '%Y'`, so a link to a directory navigates. Sorted on the phone,
stably: directories first, then case-insensitive name. Dotfiles are shown; in
a repository they are half of what matters. Each directory's entries are kept
for as long as the explorer is open, keyed by path, so returning to one does
not refetch it; the header's refresh glyph refetches the current one on
purpose, and a create refetches the directory it created into, since that is
what the operation changed.
An empty directory says "Nothing here". A listing that failed says why,
in the machine's words, where the rows would be -- never an empty list.
An empty directory says "Nothing here". A listing that failed says why, in
the machine's words, where the rows would be never an empty list.
Entries are separated by `\0` in the script's output and by `\t` within a
line (`find -printf '%y\t%Y\t%s\t%T@\t%f\0'`), so a filename with a
newline or a tab in it survives; `parse_entries` is a unit test with
exactly those names in it.
line, so a filename with a newline or a tab in it survives; `parse_entries`
is a unit test with exactly those names in it.
### 12. Icons
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script
rerun and its output committed (it needs network):
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script rerun
and its output committed: `md-folder` U+F024B (the header button and
directory rows), `md-plus` U+F0415, `md-pencil` U+F03EB,
`md-content_save` U+F0193, `md-file_outline` U+F0224. The folder and the plus
are the same codepoints dev-updater uses and must not drift from it, as the
cog and the refresh arrow already must not. All five were looked up in Nerd
Fonts' own `glyphnames.json` rather than copied from memory, which is the
check that a codepoint means the glyph its comment names.
- `md-folder` U+F024B -- the header button, and directory rows. The same
codepoint dev-updater uses, and it must not drift from it, as the cog
and the refresh arrow already must not.
- `md-plus` U+F0415 -- create. Also dev-updater's.
- `md-pencil` U+F03EB -- edit.
- `md-content_save` U+F0193 -- save.
- `md-file_outline` U+F0224 -- file rows.
**The folder button sits between the usage chart and the cog**, so the header
reads widest scope to narrowest and the cog stays at the end where every
other screen keeps it. Asked for in that order by Iris on 2026-09-03.
All five were looked up in Nerd Fonts' own `glyphnames.json` rather than
copied from memory, which is the check that a codepoint means the glyph its
comment names.
### 13. The render report moved, and the benches moved with it
**Where the folder button sits**: between the usage chart and the cog, so
the header reads widest scope to narrowest and the cog stays at the end
where every other screen in this app keeps it. Asked for in that order by
Iris on 2026-09-03.
The speedometer went; the report is a "Copy render timings" row in
`SessionSettingsDialog`, where the session's other about-the-session controls
already are. **Moving it is where the no-coordinate-taps rule got enforced**
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
### 13. The render report moves, and the benches move with it
### 14. File links in a session open in the explorer
The speedometer goes. The report it copies is the standard measurement
`transcript-bench.sh` and `stream-bench.sh` read from logcat, so it stays
reachable: a "Copy render timings" row in `SessionSettingsDialog`, which
is where the session's other about-the-session controls already are.
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.
**No script that drives the UI taps by coordinate, and moving this
button is where that rule gets enforced** (Bryan, 2026-09-03). Both bench
scripts press the button today as `ui-trace record --do 'tap 723 205'`, a
position measured once by hand. Anything that moves the header -- this
change, a font size, a density, another emulator -- makes that tap land on
whatever now sits there, and the script then reports a number that was
never measured, which reads exactly like a result. A control is found by
the name it already carries for assistive technology (`GlyphButton`'s
`label`, a row's text) and pressed at the bounds the screen reports at
that moment.
That belongs in the tool, not in each script: `ui-trace` in
`~/repos/emulator-tools` gains a tap-by-label action (`tap 'Session
settings'`, resolving the element's box from the same uiautomator tree
`elements` already reads, at the moment of the gesture), and both benches
move onto it in the same commit as the button -- cog, then "Copy render
timings" -- so the measurement is never unavailable and never wrong
quietly. `grep -n "tap [0-9]" app/*.sh` is the check that no coordinate
tap is left, and it goes in the emulator-tools README beside the action.
Once the action exists, this rule applies to every script that presses
something on an Android screen, not only these two.
The 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
Added to the table in `routes.rs`'s module doc:
```text
GET /setups/{id}/dir?path=P entries of directory P, and P resolved
GET /setups/{id}/file?path=P content of file P, or why not
PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
(409 when the file no longer matches ifSha256)
POST /setups/{id}/file {path} create empty; refused if it exists
POST /setups/{id}/dir {path} create; refused if it exists
```
Bodies use `deny_unknown_fields` like every other body here. Paths in the
query string are URL-encoded by `Api.kt`'s existing helper.
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
like every other body here; paths in the query string are URL-encoded by
`Api.kt`'s existing helper.
```json
GET dir -> {"path":"/home/bob/repos/ai-app",
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false},
{"name":"README.md","kind":"file","size":1234,"modified":1756900000,"link":false}]}
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false}]}
GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":,"sha256":"…","content":"…"}
| {"path":"/…/a.png","kind":"binary","size":45678,"modified":}
| {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":}
PUT file -> {"size":1240,"modified":,"sha256":"…"}
```
Errors: `BadRequest` with the machine's message for a path that is not
there, not allowed or not absolute; the existing 409 variant for the
precondition; `Internal` only for the server's own faults. The message is
what the phone shows, in place, so it is written to be read there.
## Server work (`server/src/files.rs`)
One module, with the same shape as `setups.rs`: the scripts as constants,
one `pub async fn` per operation taking `&Transport`, and the parsing as
pure functions with tests.
1. `Transport::capture_with_input(launch, stdin)` -- `capture` with bytes
on stdin. `ship_attachment` in `routes.rs` builds this by hand today
(an `ssh::command`, a `File` on stdin, `output().await`); it moves onto
the new helper in the same change, so there is one description of
"run this there with this on stdin" rather than two.
2. `list(transport, path) -> Listing`: `cd -- "$1" && pwd -P && find .
-mindepth 1 -maxdepth 1 -printf '%y\t%Y\t%s\t%T@\t%f\0'`. First line is
the resolved path; the rest is entries. `parse_entries` tested with
names containing a tab, a newline, a leading dash and a `'`.
3. `read(transport, path) -> Read`: `stat -c '%s %Y' -- "$1"`, refuse
above `FILE_LIMIT` before `cat` so a 2 GB log never crosses the
tunnel, then `sha256sum -- "$1"` and `cat -- "$1"`, header lines then
bytes; the server splits at the header and decides `text`/`binary` by
`String::from_utf8`.
4. `write(transport, path, expected_sha256, bytes) -> Written`: the
script in decision 6, with a distinct exit code for the precondition
(`exit 3`) that the route maps to 409; anything else is the machine's
stderr.
5. `create_file`, `create_dir`: decision 7.
6. Routes in `routes.rs`, each resolving the setup with `setup_by_id` and
`Transport::for_setup` as `set_cwd` does. The path check (absolute or
`~`) is one function shared with `set_cwd`, which has it inline today.
7. Tests: the parsers; the quoting (a path that tries to close the quote
ends up as one absurd argument -- `ssh.rs` has the pattern); and an
integration test running each script through `Transport::Here`
against a `tempfile` tree, which is cheap because `sh` is there
wherever `cargo test` runs. The precondition test writes the file
between the read and the write and asserts the 409 path.
8. PLAN.md: the Security paragraph from decision 3, and an "Explorer"
section pointing here. AGENTS.md: the layout bullet for `files.rs`.
## App work
1. `Api.kt`: `fetchDir`, `fetchFile`, `writeFile`, `createFile`,
`createDir`, and the three data classes (`DirEntry`, `FileContent`
as a sealed class with the four kinds, `Written`).
2. `NerdIcons.kt` + `build-icon-font.sh`: decision 12.
3. `Languages.kt` (or `CodeFence.kt`, wherever `FENCE_LANGUAGES` sits):
`fileLanguage(name)`.
4. `FileLines.kt`: the pure half of the viewer -- spans bucketed per line,
`lineOf(index) -> AnnotatedString` -- so it has a JVM unit test beside
`HighlighterTest`, the app's one existing test suite, covering a block
comment that spans lines and a file with no trailing newline.
5. `FilesScreen.kt`: the listing, the navigation stack, the per-directory
cache, the create dialog (modelled on `AddSetupDialog`: fields, a busy
state, the failure shown inside the dialog beside the button that
caused it), and the header. `LoadState` for the listing.
6. `FileViewer.kt`: decision 8. `FileEditor.kt`: decision 9, including
the conflict dialog.
7. `AppRoot.kt`: decision 10. `SessionScreen.kt`: the folder glyph where
the speedometer was, `onFiles(setup, cwd)` out to the root.
8. `SessionSettingsDialog.kt`: the render-report row. In
`~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the
two bench scripts onto it, with no coordinate tap left in `app/*.sh`.
Errors: `BadRequest` with the machine's message for a path that is not there,
not allowed or not absolute; 409 for the precondition; `Internal` only for
the server's own faults. The message is what the phone shows, in place, so it
is written to be read there.
## What the measurements said (2026-09-04)
Taken on the emulator in a **debug** build, which runs Compose at a
fraction of release speed and renders in software -- so these rank
correctly against each other and are pessimistic in absolute terms.
Generated Rust, through the app's own render report.
Taken on the emulator in a **debug** build, which runs Compose at a fraction
of release speed and renders in software so these rank correctly against
each other and are pessimistic in absolute terms. Generated Rust, through the
app's own render report.
| file | lines | scan + cut | scan per keystroke | worst frame record |
|--------|--------|------------|--------------------|--------------------|
@@ -451,32 +331,29 @@ Generated Rust, through the app's own render report.
Three things followed.
**The viewer's scan had to leave the main thread.** Decision 8 said "off
the main thread" and the first version did it in a `remember` inside the
composition, which is not that: 460ms of frozen screen at the size the
server is willing to send, long enough that the accessibility tree cannot
be read -- which is exactly what "the app has stopped" looks like from
outside. It now runs on `Dispatchers.Default` with a spinner where the file
will be.
**The viewer's scan had to leave the main thread.** Decision 8 said "off the
main thread" and the first version did it in a `remember` inside the
composition, which is not that: 460ms of frozen screen at the size the server
is willing to send, long enough that the accessibility tree cannot be read —
which is exactly what "the app has stopped" looks like from outside.
**`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a
1 MiB file, tap to text on screen, was **2.4s** against the sandbox --
1.2s of which is that server's deliberate `--delay`, and 460ms the scan.
The transfer is not what dominates, so the route gains nothing from
streaming.
1 MiB file, tap to text on screen, was **2.4s** against the sandbox — 1.2s of
which is that server's deliberate `--delay`, and 460ms the scan. The transfer
is not what dominates, so the route gains nothing from streaming.
**Edit mode needed a cap, and not the one that was expected.** The plan
expected to be deciding a size below which highlighting stays on. That is
not the cost that matters: highlighting 128 kB costs 40ms a keystroke,
which is survivable, while laying the same text out in one
`BasicTextField` costs two seconds -- characters typed into it were
dropped, and a 1 MiB file stopped the app responding altogether. Since
every arrangement of a single text field pays that, switching highlighting
off would have saved nothing. So `EDIT_LIMIT` is **32 kB**, the largest
size measured as usable, and above it the pencil is disabled with the
reason said in words beside it -- a disabled control teaches what the thing
can do but cannot say why it is off, and a reader who cannot edit a file
they can plainly read would otherwise conclude the app is broken.
expected to be deciding a size below which highlighting stays on. That is not
the cost that matters: highlighting 128 kB costs 40ms a keystroke, which is
survivable, while laying the same text out in one `BasicTextField` costs two
seconds characters typed into it were dropped, and a 1 MiB file stopped the
app responding altogether. Since every arrangement of a single text field
pays that, switching highlighting off would have saved nothing. So
`EDIT_LIMIT` is **32 kB**, the largest size measured as usable, and above it
the pencil is disabled with the reason said in words beside it — a disabled
control teaches what the thing can do but cannot say why it is off, and a
reader who cannot edit a file they can plainly read would otherwise conclude
the app is broken.
Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine,
because it is a `LazyColumn` of lines rather than one text object. That
@@ -484,21 +361,20 @@ difference is the whole of decision 8.
## Later, deliberately not now
- Delete, rename and move. Destructive controls belong here eventually,
shown and confirmed rather than hidden, but none of them is needed to
read or change a file.
- Delete, rename and move. Destructive controls belong here eventually, shown
and confirmed rather than hidden, but none is needed to read or change a
file.
- Images in the viewer, through the existing `SessionImageViewer`.
- Following an agent's edits live: a file open in the viewer refreshing
when a `Write`/`Edit` tool call on the same path lands in the
transcript. The transcript already knows the path.
- Following an agent's edits live: a file open in the viewer refreshing when
a `Write`/`Edit` tool call on the same path lands in the transcript. The
transcript already knows the path.
- Remembering the last directory per session.
- Uploading from the phone into a directory. Attachments already do the
upload half; this would be the same route with a chosen destination.
upload half.
- Search within a file, and find-in-files.
- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The
viewer already draws a file as rows and stays fast on a megabyte; an
editor built the same way -- a field per line, or a field over the lines
on screen -- would not pay Compose's cost of laying out one enormous
text. It is a good deal more than this feature needed, and 32 kB covers
the config files, notes and ordinary source files anybody edits from a
phone.
- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The viewer
already draws a file as rows and stays fast on a megabyte; an editor built
the same way a field per line, or a field over the lines on screen —
would not pay Compose's cost of laying out one enormous text. It is a good
deal more than this feature needed, and 32 kB covers the config files,
notes and ordinary source files anybody edits from a phone.
+1790 -1052
View File
File diff suppressed because it is too large. Load diff
+289
View File
@@ -0,0 +1,289 @@
# 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.
+22
View File
@@ -5,6 +5,10 @@ one in place when it turns out to need a decision.
## 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
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
@@ -33,3 +37,21 @@ 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
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.
+281 -366
View File
@@ -1,419 +1,343 @@
# The transcript cache
Asked for by Iris on 2026-09-04: keep the transcripts of recently visited
sessions on the phone, so reopening one does not download it again. It has
to save data over the tunnel, it must not disturb a reply that is streaming
when the screen is reopened, it must never skip an event, and session
settings needs a manual reload for when the file on the machine has
changed under it.
Asked for by Iris on 2026-09-04 and built the same day: keep the transcripts
of recently visited sessions on the phone, so reopening one does not download
it again. It has to save data over the tunnel, must not disturb a reply that
is streaming when the screen is reopened, must never skip an event, and needs
a manual reload for when the file on the machine has changed under it.
Built 2026-09-04. Like EXPLORER.md this records each decision with its
reason and what was rejected, so that when one changes it is changed here
rather than re-argued -- three of them changed during the building, and
"What building it changed" at the foot says which and why. What it is *not*
is the operational half: how to exercise it, and what has bitten, are in
AGENTS.md with the rest of the working notes.
Like EXPLORER.md this records each decision with its reason and what was
rejected, so that when one changes it is changed here rather than re-argued.
"What building it changed" at the foot says which of them moved while it was
being built. How to exercise it, and what has bitten, are in AGENTS.md.
## What it is, in one paragraph
A per-session file on the phone holding the exact JSON lines the server has
already sent, in transcript order, with a record of which sequence numbers
each run of lines covers. Everything the session screen fetches today --
the opening window, the pages it scrolls back through, the span an anchor
restore reaches for -- is asked of the cache first and of the server only
for what the cache does not hold, and everything that arrives from the
server is written into it. The live stream then resumes from the newest
cached event, exactly as it resumes today from the newest event on screen,
so the server sends only what happened since. One tiny request checks that
the cached tail is still what the server has before the stream is opened
from it, and a button in session settings throws the cache away and
rebuilds the screen as a cold open for the cases that check cannot see.
each run of lines covers. Everything the session screen fetches — the opening
window, the pages it scrolls back through, the span an anchor restore reaches
for is asked of the cache first and of the server only for what the cache
does not hold, and everything that arrives from the server is written into
it. The live stream then resumes from the newest cached event, exactly as it
resumes from the newest event on screen, so the server sends only what
happened since. One tiny request checks that the cached tail is still what
the server has before the stream is opened from it, and a button in session
settings throws the cache away and rebuilds the screen as a cold open for the
cases that check cannot see.
## The invariants
Everything below is in service of four rules. When a decision looks
arbitrary, it is one of these forcing it.
When a decision below looks arbitrary, it is one of these forcing it.
1. **What is on screen is what the server's transcript says, in order,
with nothing missing, for every sequence number the screen claims to
show.** The cache is a copy of server output and is never inferred,
folded, or edited on the phone. Where the copy cannot be shown to be
current, it is thrown away, not patched.
2. **A cached line is never ahead of the live cursor, and the live cursor
is never ahead of the cache.** The stream resumes from the newest cached
1. **What is on screen is what the server's transcript says, in order, with
nothing missing, for every sequence number the screen claims to show.**
The cache is a copy of server output and is never inferred, folded, or
edited on the phone. Where the copy cannot be shown to be current, it is
thrown away, not patched.
2. **A cached line is never ahead of the live cursor, and the live cursor is
never ahead of the cache.** The stream resumes from the newest cached
event, so a reply that was mid-stream when the screen closed picks up at
its next delta and folds into the same row, as it does today when the
phone merely lost the tunnel for a second.
its next delta and folds into the same row.
3. **The cache is never load-bearing.** A missing, evicted, corrupt or
unwritable cache degrades to today's behaviour -- a cold open -- and
never to a blank or wrong screen. Every path that reads it has a
network path beside it that produces the same result.
unwritable cache degrades to a cold open, never to a blank or wrong
screen. Every path that reads it has a network path beside it producing
the same result.
4. **Data crosses the tunnel once.** A line already on the phone is not
fetched again unless the reader asks for that (the reload button) or the
check in decision 3 says it must be.
fetched again unless the reader asks (the reload button) or the check in
decision 3 says it must be.
## Decisions
### 1. Raw server lines, on the phone, keyed by server and session
The cache stores the server's own JSON, one event per line, byte-for-byte
as it arrived: the elements of the `/transcript` array and the `data:`
payload of each SSE frame. Reading the cache means running the same
`parseSeqEvent` the network path runs, so a cached transcript and a fetched
one cannot draw differently, and an event type this build does not know
The cache stores the server's own JSON, one event per line, byte-for-byte as
it arrived: the elements of the `/transcript` array and the `data:` payload
of each SSE frame. Reading the cache runs the same `parseSeqEvent` the
network path runs, so a cached transcript and a fetched one cannot draw
differently, and an event type this build does not know
(`SessionEvent.Unknown`) survives on disk for the build that will.
It lives under `context.cacheDir` -- `<cacheDir>/transcripts/v1/<host>_<port>/<sessionId>/`
-- because it is exactly what that directory is for: bytes the phone can
regenerate from the server, which Android may delete under storage
pressure without asking. Keyed by the server's host and port because two
servers can hold a session with the same id (the sandbox and the real
server, or a re-enrolment), and a line from one shown against the other
is invariant 1 broken. `ServerSettings` has both fields; the key is
`"${settings.host}_${settings.port}"` with `:` never appearing in it.
The `v1` segment is the format version: any change to the layout below
bumps it, and a directory of another version is deleted on first use.
It lives under `context.cacheDir`, which is exactly what that directory is
for: bytes the phone can regenerate from the server, which Android may delete
under storage pressure without asking. Keyed by the server's host and port,
because two servers can hold a session with the same id (the sandbox and the
real server, or a re-enrolment) and a line from one shown against the other
is invariant 1 broken. The `v1` segment is the format version: any change to
the layout below bumps it, and a directory of another version is deleted on
first use.
Rejected: a database (Room, SQLite). The access pattern is "the newest N
lines" and "the lines before seq X", on files of tens of megabytes at most,
and a JSONL file per contiguous run answers both by reading from its end.
A database would be a new dependency for an index the file layout already
provides.
and a JSONL file per contiguous run answers both by reading from its end. A
database would be a new dependency for an index the file layout provides.
Rejected: caching folded `TranscriptItem` rows instead of events. Rows are
a *rendering* of events, and their shape changes when the fold changes;
the cache would need invalidating on every app update that touched
`foldEvent`, and would still have to keep raw seqs for the stream cursor.
Events are the server's contract and the only thing that is stable.
Rejected: caching folded `TranscriptItem` rows instead of events. Rows are a
*rendering* of events, and their shape changes when the fold changes; the
cache would need invalidating on every app update that touched `foldEvent`,
and would still have to keep raw seqs for the stream cursor. Events are the
server's contract and the only thing that is stable.
### 2. Chunks with explicit coverage; one contiguous run behind the cursor
A page from the server is a set of lines *and a claim about what they
cover*, and the two are not the same thing. A coalesced page
(`coalesce=true`, which the scroll-back pager asks for) joins each run of
`assistantText` deltas into one event carrying the seq of its *oldest*
delta, so a page whose newest event has seq 1,200 may in fact cover every
line up to the `before` it was asked with, say 1,650. Nothing in the lines
themselves says so. So each stored chunk records its coverage as a
half-open range `[first, end)`, where `first` is the seq of its oldest
event and `end` is the `before` the request was made with -- or, for a
raw chunk, its newest seq plus one.
A page from the server is a set of lines *and a claim about what they cover*,
and the two are not the same thing. A coalesced page joins each run of
`assistantText` deltas into one event carrying the seq of its *oldest* delta,
so a page whose newest event has seq 1,200 may in fact cover every line up to
the `before` it was asked with, say 1,650. Nothing in the lines themselves
says so. So each stored chunk records its coverage as a half-open range
`[first, end)`, where `end` is the `before` the request was made with — or,
for a raw chunk, its newest seq plus one.
Chunks are files named by their coverage:
<first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
<first>-<end>.raw.jsonl an uncoalesced page or a closed live run
<first>-open.raw.jsonl the live run: appended to by the stream; end = last line's seq + 1
<first>-open.raw.jsonl the live run: appended to by the stream
Two chunks are **adjacent** when one's `end` equals the other's `first`.
The cache serves only the contiguous run of adjacent chunks that ends at
the newest raw chunk (the **suffix**); chunks behind a gap are kept on
disk, because the gap is usually filled (decision 4), but are never served
across the gap.
Two chunks are **adjacent** when one's `end` equals the other's `first`. The
cache serves only the contiguous run of adjacent chunks that ends at the
newest raw chunk (the **suffix**); chunks behind a gap are kept on disk,
because the gap is usually filled (decision 4), but are never served across
it.
**The newest chunk is always raw.** That is what makes the stream cursor
and the check in decision 3 well defined: a raw chunk's last line is a real
event at a real seq, and the server never coalesces the newest window
("the live cursor depends on real seqs", `read_window`). It holds by
construction -- the opening window is fetched with no `before`, stream
frames are raw, and a `reset` window is raw -- and is *checked* on read:
if the newest chunk on disk is a `.rows` chunk (which can only happen if
the app died between closing one live run and appending to the next), the
session's cache is purged and the open is cold.
**The newest chunk is always raw.** That is what makes the stream cursor and
the probe well defined: a raw chunk's last line is a real event at a real
seq, and the server never coalesces the newest window. It holds by
construction — the opening window is fetched with no `before`, stream frames
are raw, and a `reset` window is raw — and is *checked* on read: a `.rows`
chunk found newest (which can only happen if the app died between closing one
live run and appending to the next) purges the session's cache.
There is at most one open chunk. When a stream event arrives whose seq is
not the open chunk's `end` -- which is what a `reset` looks like from
here, see decision 6 -- the open chunk is closed by renaming it with its
real end, and a new open chunk starts at the arriving seq. An event whose
seq is below the open chunk's `end` is already covered and is not written
(the SSE contract is `seq > after`, so this is a guard, not a path).
There is at most one open chunk. A stream event whose seq is not the open
chunk's `end` which is what a `reset` looks like from here — closes it by
renaming it with its real end and starts a new one. An event whose seq is
below the open chunk's `end` is already covered and is not written; the SSE
contract is `seq > after`, so that is a guard rather than a path.
Rejected: one file per session, rewritten to prepend older pages. A
20 MB transcript would be rewritten on every page scrolled back to. The
chunk directory costs a directory listing per open instead.
Rejected: one file per session, rewritten to prepend older pages. A 20 MB
transcript would be rewritten on every page scrolled back to. The chunk
directory costs a directory listing per open instead.
Rejected: trimming chunks to resolve overlaps. A coalesced event cannot be
split at a seq inside its run, so an overlap between a coalesced page and
an existing chunk has no clean cut. The cache therefore **never stores a
page that overlaps an existing chunk**; decision 4 makes sure such a page
is never fetched in the first place, and if one arrives anyway (a server
without decision 4's change) it is used for display and not stored.
split at a seq inside its run, so an overlap between a coalesced page and an
existing chunk has no clean cut. The cache therefore **never stores a page
that overlaps an existing chunk**; decision 4 makes sure such a page is never
fetched, and one that arrives anyway is used for display and not stored.
### 3. The cached tail is checked against the server before the stream opens from it
The screen must not resume a stream from a cached seq unless the server's
event at that seq is the one in the cache. The transcript file on the
machine is append-only in ordinary use, but it can be replaced or
truncated -- a sandbox re-seeded with the same ids, a backup restored, a
directory deleted and the session re-imported under the same name -- and
`catch_up` on such a file would hand the phone a continuation of a
different conversation, spliced onto the cached one with no seam. That is
the worst thing this feature can do, and it is caught with one request.
The transcript file is append-only in ordinary use, but it can be replaced or
truncated — a sandbox re-seeded with the same ids, a backup restored, a
session deleted and re-imported — and `catch_up` on such a file would hand
the phone a continuation of a *different* conversation, spliced onto the
cached one with no seam. That is the worst thing this feature can do, and it
is caught with one request.
**The probe:** `GET /sessions/{id}/transcript?before=<cursor+1>&limit=1`,
where `cursor` is the seq of the cache's newest line. `read_window` with
that `before` returns the single newest event with seq ≤ cursor, which is
the event *at* the cursor when it exists. The probe passes when that
response, parsed with `parseSeqEvent`, is `==` to the cached line parsed
the same way -- data-class equality over seq, ts, and the whole event. It
fails when the response is empty, is a different seq, or differs in any
field.
**The probe** is `GET /sessions/{id}/transcript?before=<cursor+1>&limit=1`,
where `cursor` is the seq of the cache's newest line. `read_window` with that
`before` returns the single newest event with seq ≤ cursor, which is the
event *at* the cursor when it exists. It passes when that response, parsed
with `parseSeqEvent`, is `==` to the cached line parsed the same way — over
seq, ts, and the whole event. It fails when the response is empty, is a
different seq, or differs in any field.
That equality rested on an assumption this plan stated and did not check:
that the two ways the server hands out a line agree bit for bit. **They did
not.** `serde_json`'s default float parser is not correctly rounded, so a
`ts` of `1788546972.6030757` written to the transcript came back from
`/transcript` as `...0755`, while the SSE stream -- serializing the same
struct -- sent the original. Measured on the emulator 2026-09-04: 23 of 330
cached lines differed from the server's answer in the last bit, so the probe
would have failed on any session whose cached tail happened to be one of
them, silently and only sometimes. That is a defect in the server
independent of this feature -- two answers to "what is line 30" -- and it is
fixed there, with `float_roundtrip` and a test
(`a_line_read_back_is_the_line_that_was_written`) that fails the moment the
feature is dropped. Comparing everything *except* `ts` was the other option
and was rejected: a re-seeded fixture is identical in content and differs
only in when it happened, which is exactly the case the probe exists for. A failed probe **purges the session's cache
and proceeds as a cold open**. A probe that cannot be made (no route to
the server) leaves the cached transcript on screen, shows the request's
not**, and the server was fixed — see AGENTS.md's entry on `float_roundtrip`.
Comparing everything *except* `ts` was the other option and was rejected: a
re-seeded fixture is identical in content and differs only in when it
happened, which is exactly the case the probe exists for.
A failed probe **purges the session's cache and proceeds as a cold open**. A
probe that cannot be made leaves the cached transcript on screen, shows the
error on the stream banner where a connection failure shows today, and is
retried on the stream loop's schedule (`RECONNECT_DELAY_MS`); the stream
is never opened until a probe has passed once for this screen instance.
retried on the stream loop's schedule; the stream is never opened until a
probe has passed once for this screen instance.
What the probe does *not* catch: a line changed in the middle of the file
with the tail intact, or a file rewritten so that the event at the cursor
happens to be identical. Those are what the reload button is for, and the
button's caption says so.
Cost: one request of a few hundred bytes, one round trip, in the slot
where the opening page's request is today -- so the round trips before
the stream is live are unchanged at two, and the bytes fall from a page to
a line. The cached rows are drawn *before* the probe returns, which is the
whole point of the feature; a failed probe replaces them, the same
appearance as a `reset`.
Cost: one request of a few hundred bytes, in the slot where the opening
page's request would be — so the round trips before the stream is live are
unchanged at two, and the bytes fall from a page to a line. The cached rows
are drawn *before* the probe returns, which is the whole point; a failed
probe replaces them, with the same appearance as a `reset`.
Rejected: a server-side check on the stream (`events?after=N&ts=T`,
answered with a distinct frame when the event at N is not what the phone
thinks). Strictly better coverage -- it would run on every reconnect, not
only on open -- and no extra round trip. Not chosen because it puts a
cache's validation into a protocol that otherwise knows nothing about
caching, and because the reset frame already has to keep meaning "you are
behind, your history is fine" (decision 6), so a second frame would be
needed. Worth revisiting if the probe's round trip is ever measured as the
thing making reopen slow; note it as the alternative here and in PLAN.md.
Rejected: a server-side check on the stream, answered with a distinct frame
when the event at N is not what the phone thinks. Strictly better coverage —
it would run on every reconnect — and no extra round trip. Not chosen because
it puts a cache's validation into a protocol that otherwise knows nothing
about caching, and because the reset frame already has to keep meaning "you
are behind, your history is fine". Worth revisiting if the probe's round trip
is ever measured as the thing making reopen slow.
Rejected: trusting the cache without a check and relying on the reload
button. Invariant 1 is not something a button restores after the fact.
Rejected: trusting the cache and relying on the reload button. Invariant 1 is
not something a button restores after the fact.
Rejected: fetching the newest page as today and using it to validate the
overlap. Zero saving on the opening page, which is the request paid on
every open.
Rejected: fetching the newest page as before and using it to validate the
overlap. Zero saving on the opening page, which is the request paid on every
open.
### 4. Pages ask the server only for the gap: `after` on `/transcript`
After a reader has been away, the cache holds `[a, b)` and the screen
holds the newest window `[W, …)` with a gap between `b` and `W`. Paging
back from `W` asks the server for a coalesced page before `W`, and that
page may reach back past `b` -- a single reply is hundreds of lines, so
forty rows can be thousands of seqs -- producing exactly the overlap
decision 2 refuses to store. Left like that, every cached chunk would be
overlapped and dropped in turn as the reader paged back through the gap,
and the cache would save nothing for the sessions it exists for.
After a reader has been away, the cache holds `[a, b)` and the screen holds
the newest window `[W, …)` with a gap between `b` and `W`. Paging back from
`W` asks for a coalesced page before `W`, and that page may reach back past
`b` a single reply is hundreds of lines, so forty rows can be thousands of
seqs producing exactly the overlap decision 2 refuses to store. Left like
that, every cached chunk would be dropped in turn as the reader paged back
through the gap, and the cache would save nothing for the sessions it exists
for.
So the transcript route gains a lower bound. `TranscriptQuery` in
`server/src/routes.rs` gets
So the transcript route takes a lower bound, `after`, named to match the SSE
route's (exclusive, `seq > after`). `read_window` starts the walk at
`first_at_or_after(after + 1)` instead of at `end - limit`. A delta run cut
at the start is emitted as the partial it is, exactly as one cut by `limit`
already is, and `healSplitMessage` welds it on the phone — no new mechanism.
/// Return nothing at or below this seq; the page stops here instead of at `limit`.
/// The phone passes the end of what it already holds, so a page never overlaps it.
#[serde(default)]
after: Option<u64>,
The phone passes `after = b - 1` where `b` is the `end` of the nearest chunk
whose `end ≤ before`, and nothing when there is none. A page that comes back
with `first == b` is adjacent, and the suffix now runs through the old
chunks: the gap is closed with exactly the bytes it was wide, and the history
behind it is served locally from then on.
named to match the SSE route's `after` (exclusive, `seq > after`).
`read_window(path, before, after, limit, coalesce)` in
`server/src/session/transcript.rs` computes
`start = first_at_or_after(after + 1)` and stops the walk there: the raw
branch parses `max(start, end - limit)..end`; `parse_coalesced` takes a
`start` and its `while index > 0` becomes `while index > start`. A delta
run cut at `start` is emitted as the partial it is, exactly as one cut by
`limit` already is, and `healSplitMessage` welds it on the phone -- no new
mechanism. The route's table comment in `routes.rs` gains the parameter,
and `transcript.rs` gets a test beside
`a_window_is_the_events_before_a_cursor_and_nothing_else`: with `after`
set, the page's oldest seq is greater than `after`, and with `after` set
inside a delta run the partial run's seq is the first delta above `after`.
The phone passes `after = b - 1` where `b` is the `end` of the nearest
chunk whose `end ≤ before`, and nothing when there is none. A page that
comes back with `first == b` is adjacent, and the suffix now runs through
the old chunks: the gap is closed with exactly the bytes it was wide, and
the history behind it is served locally from then on.
Rejected: fetching the gap raw in one request (`before=W&limit=W-b`,
which is what the anchor restore already does). Exact, but a gap of ten
thousand lines is several megabytes downloaded to save re-downloading
history the reader may never scroll to; the feature exists to save data.
Paging as today with a bound saves the same bytes and fetches only what
is read.
Rejected: fetching the gap raw in one request, which is what the anchor
restore does. Exact, but a gap of ten thousand lines is several megabytes
downloaded to save re-downloading history the reader may never scroll to.
Rejected: dropping the cached run whenever a gap opens. Being more than
`CATCH_UP_LIMIT` (200) events behind is the *ordinary* state of an active
session revisited -- 200 raw events is one reply -- so this would empty
the cache for exactly the sessions that are opened most.
session revisited 200 raw events is one reply so this would empty the
cache for exactly the sessions that are opened most.
### 5. A page is served locally in rows, mirroring the server's count
`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when
`coalesce = true`, and for a number of **events** otherwise (the anchor
restore). Served from the cache, the events branch is the `limit` lines
before `before`. The rows branch walks back from the line before `before`
counting rows the way `parse_coalesced` does: every event that is not an
`assistantText` is a row, and each maximal run of `assistantText` lines is
one row; it stops only between rows, once `limit` rows are complete, and
returns the raw lines oldest-first. It does not join the deltas -- the
fold does that (`foldEvent` appends a delta to a preceding
`AssistantMsg`), and the joined row keeps the seq of its first delta either
way, so anchors and the next `before` land where they do today.
`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when coalescing and for
a number of **events** otherwise (the anchor restore). Served from the cache,
the events branch is the `limit` lines before `before`. The rows branch walks
back counting rows the way `parse_coalesced` does — every event that is not
an `assistantText` is a row, and each maximal run of `assistantText` lines is
one row — stopping only between rows. It does not join the deltas; the fold
does that, and the joined row keeps the seq of its first delta either way, so
anchors and the next `before` land where they do on the network path.
A cached page is allowed to be **short**: the suffix's oldest chunk starts
at some `first`, and a walk that reaches it returns what it found. The
caller already treats a short page as a page; only an *empty* page means
"start of the conversation" (`moreHistory = false`), and the cache never
returns an empty page -- it returns `null` (a miss) and the network is
asked. The walk may cross a chunk boundary inside the suffix, since adjacent
chunks are one run; a delta run straddling a boundary counts as one row, as
it should.
A cached page is allowed to be **short**: a walk that reaches the suffix's
oldest chunk returns what it found. The caller already treats a short page as
a page; only an *empty* page means "start of the conversation", and the cache
never returns one — it returns `null` (a miss) and the network is asked.
A miss is `before` **outside what the suffix covers continuously** -- above
A miss is `before` **outside what the suffix covers continuously** above
its newest `end`, or at or below its oldest `first`. This plan first said a
miss was "no chunk of the suffix ends at `before`", which is wrong in the
commonest case there is: a warm open draws the newest eighty lines of the
live run, so the cursor the reader then scrolls back from is in the *middle*
of a chunk, not at a boundary. Under the narrower rule every warm open sent
its first backwards page to the server, and that page -- reaching back past
the run the phone already held -- overlapped it and could not be stored, so
the same history was fetched again on every visit. The feature would have
saved the opening window and nothing else.
of a chunk. Under the narrower rule every warm open sent its first backwards
page to the server, and that page overlapped what the phone already held and
could not be stored, so the same history was fetched again on every visit.
The feature would have saved the opening window and nothing else.
The row rule is a copy of the server's, and copies drift. It is short
(one comparison), it is pure, and it goes under a JVM unit test with the
same fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`
-- the three cases are a run cut by the limit, a `usageDelta` inside a run
(the server flushes the run there, so it is two rows), and a page that is
all one run.
The row rule is a copy of the server's, and copies drift. It is short, it is
pure, and it is under a JVM unit test with the same fixture as the server's
`coalescing_counts_rows_and_joins_delta_runs` — a run cut by the limit, a
`usageDelta` inside a run (the server flushes the run there, so it is two
rows), and a page that is all one run.
### 6. What a `reset` means for the cache: behind, not wrong
The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT`
events behind, then the newest 200 raw events. The screen already drops
everything and rebuilds from that window. For the cache, a reset means
**the history is intact and there is a gap**: the probe passed, the file
is append-only, and the window's first seq is above the open chunk's end.
The store learns this from the first window event's seq (decision 2:
a seq that is not the open chunk's `end` closes it and opens a new chunk)
and needs no signal from the screen; the gap is filled by paging
(decision 4).
The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT` events
behind, then the newest 200 raw events. For the cache that means **the
history is intact and there is a gap**: the probe passed, the file is
append-only, and the window's first seq is above the open chunk's end. The
store learns this from the first window event's seq and needs no signal from
the screen; the gap is filled by paging.
Two things the reset handler in `SessionScreen` does not clear today and
must: `queued` and `waitingCommands`. Both are folded from events, and a
`messageQueued` whose resolving `userMessage` fell in the gap would
otherwise draw a waiting bubble for a message the session has long since
read. This is a latent bug today, made likely by the cache because a
cached tail is older than a fetched one. `contextTokens` needs no change:
`UsageDelta.context` is absolute, so the window's first one corrects it.
The reset handler also clears `queued` and `waitingCommands`, which it did
not originally. Both are folded from events, and a `messageQueued` whose
resolving `userMessage` fell in the gap would otherwise draw a waiting bubble
for a message the session has long since read. That was a latent bug made
likely by the cache, because a cached tail is older than a fetched one.
`contextTokens` needs no clearing: `UsageDelta.context` is absolute, so the
window's first one corrects it.
### 7. Session state that is not the transcript comes from the list, not the cache
`apply` derives `status`, `model`, `permissionMode` and `compactingSince`
from `Status` and `Settings` events. Replayed from a fetched page those are
current; replayed from the cache they are as old as the last visit, while
`summary.status`, `summary.model` and `summary.permissionMode` -- the row
the reader just tapped -- were fetched moments ago. So the cache replay
runs through `apply` for the transcript's sake (queued bubbles, context,
rows) and then **reassigns those four from `summary`**, which is the newer
of the two measurements; the stream's catch-up then makes them current.
Without this a session that finished an hour ago would open saying
"working" until the stream connected, which is a status row lying for a
round trip.
current; replayed from the cache they are as old as the last visit, while the
list row the reader just tapped was fetched moments ago. So the cache replay
runs through `apply` for the transcript's sake and then **reassigns those
four from `summary`**, which is the newer of the two measurements; the
stream's catch-up then makes them current. Without this a session that
finished an hour ago would open saying "working" until the stream connected,
which is a status row lying for a round trip.
### 8. Reload, in session settings
`SessionSettingsDialog` gains a row under the working directory:
A row under the working directory showing what the button discards:
[ Transcript ] 2.3 MB cached [ Reload ]
The size is what the button discards, and it is the unknown state made
visible: `null` while the directory is being measured (spinner, as the
notifications switch does), "nothing cached" when the directory is absent
or empty, else the size. A caption in the style of Move's, because the
button costs something the reader cannot see:
The size is the unknown state made visible — `null` while the directory is
being measured (spinner, as the notifications switch does), "nothing cached"
when the directory is absent or empty, else the size. The caption is in the
style of Move's, because the button costs something the reader cannot see:
*"Reload throws away this phone's copy and fetches the transcript from the
server again. Use it when what is shown here disagrees with the file on the
machine."*
Reload throws away this phone's copy and fetches the transcript from the
server again. Use it when what is shown here disagrees with the file on
the machine.
Pressing it purges the session's cache directory, closes the dialog, and
rebuilds the screen as a cold open, with the reader put back where they were.
The mechanism is an `epoch` counter in the key of the opening effect and the
stream effect; incrementing it cancels both and relaunches them. `savedAnchor`
is keyed on the epoch too, so the restore reads the anchor saved at the
reader's *current* position. The button is enabled whether or not anything is
cached: "what I see disagrees with the machine" is a state an empty cache can
also be in, and a control that comes and goes makes its own presence the
signal.
Pressing it: purge the session's cache directory, close the dialog, and
rebuild the screen as a cold open -- the same sequence as `reset` plus a
fresh opening fetch, with the reader put back where they were. The
mechanism is an `epoch` counter (`mutableIntStateOf(0)`) added to the key
of the opening effect and the stream effect; incrementing it cancels both
(the stream's `finally` closes the socket) and relaunches them. State the
relaunch must see cleared: `items`, `replies.clear()`, `held`, `oldestSeq
= 0`, `moreHistory = true`, `queued`, `waitingCommands`, `lastSeq.set(0)`,
`ready = false`. `savedAnchor` becomes `remember(summary.id, epoch)` so
the restore path reads the anchor saved at the reader's *current*
position (the anchor saver writes on every settle, so it is there), and
`restoring` is re-derived from it. The button is enabled whether or not
anything is cached: "what I see disagrees with the machine" is a state an
empty cache can also be in, and a control that comes and goes makes its
own presence the signal.
Nothing is announced on success — the transcript shows the opening spinner
and then the rows, which is what the screen already says about a reload. A
failure is the opening fetch's, and lands on the stream banner.
Nothing is announced on success. The transcript shows the opening spinner
and then the rows, which is what the screen already says about a reload.
A failure is the opening fetch's, and lands on the stream banner where
that failure lands today.
Rejected: a global "clear transcript cache" in the app's settings screen.
Not asked for; eviction (decision 9) bounds the total, and the per-session
button is where the reader is when they notice a problem. Easy to add as
one more caller of `TranscriptCache.purgeAll` if wanted.
Rejected: a global "clear transcript cache" in the app's settings. Not asked
for; eviction bounds the total, and the per-session button is where the
reader is when they notice a problem. Easy to add as one more caller of
`purgeAll`.
### 9. Budget, eviction, pruning
The cache is bounded three ways, each with its path out written beside
the path in:
Bounded three ways, each with its path out written beside the path in:
- **Budget.** `CACHE_BUDGET_BYTES = 256 MB` across all sessions of one
server. Each open touches the session directory's mtime; after the
opening replay, on `Dispatchers.IO`, the store sums the server's
directories and deletes least-recently-touched session directories
(never the one on screen) until under budget. 256 MB is a dozen of the
largest transcripts seen in this VM (21 MB for 24,000 events) and a
small fraction of a phone; it is a number to revisit against real use,
not a measurement.
- **Deleted sessions.** `SessionListScreen`'s delete calls
`cache.session(id).purge()` after `deleteSession` succeeds, and every
successful list fetch calls `cache.retainOnly(ids)` for that server, so
a session deleted from another device or from the backend is pruned on
the next visit to the list. `Drafts.kt` chose not to prune because its
residue is bytes; here it is megabytes, so the pass is worth having.
- **Android.** `cacheDir` may be emptied under pressure at any moment,
including while a screen is open. Every read tolerates a missing
directory (cold open) and every write failure is swallowed once and
disables writing for that screen instance (decision 10).
- **Budget.** `CACHE_BUDGET_BYTES` is 256 MB across all sessions of one
server. Each open touches the session directory's mtime; after the opening
replay, on `Dispatchers.IO`, the store sums the server's directories and
deletes least-recently-touched ones (never the one on screen) until under
budget. 256 MB is a dozen of the largest transcripts seen in this VM
(21 MB for 24,000 events) and a small fraction of a phone; it is a number
to revisit against real use, not a measurement.
- **Deleted sessions.** The list screen's delete purges after `deleteSession`
succeeds, and every successful list fetch calls `retainOnly(ids)`, so a
session deleted from another device is pruned on the next visit to the
list. `Drafts.kt` chose not to prune because its residue is bytes; here it
is megabytes.
- **Android.** `cacheDir` may be emptied at any moment, including while a
screen is open. Every read tolerates a missing directory and every write
failure is swallowed once.
### 10. The cache never breaks the screen
Every store operation that touches the disk catches `IOException` and
answers as if the cache were empty: `null` from a read, no-op from a
write, with the failure logged once at `Log.w("ai-app", …)`. After a
write failure the `SessionCache` instance sets `disabled = true` and
writes nothing more, so a full disk costs one log line rather than one
per delta. A line at the end of an open chunk that does not parse -- the
app died mid-write -- is dropped and the file truncated to the last
good line before anything is served from it; a line that does not parse
anywhere else purges the session's cache (that file was not written by
this code). None of this is reported on screen: none of it changes what
the screen shows, and the reader has nothing to do about it.
Every store operation that touches the disk catches `IOException` and answers
as if the cache were empty: `null` from a read, no-op from a write, logged
once. After a write failure the instance stops writing, so a full disk costs
one log line rather than one per delta. A line at the end of an open chunk
that does not parse — the app died mid-write — is dropped and the file
truncated to the last good line before anything is served from it; a line
that does not parse anywhere else purges the session's cache, since that file
was not written by this code. None of this is reported on screen: none of it
changes what the screen shows, and the reader has nothing to do about it.
## Layout on disk
@@ -424,12 +348,12 @@ the screen shows, and the reader has nothing to do about it.
1-1650.rows.jsonl coalesced page: covers seqs 1..1649
1650-2001.rows.jsonl
2001-2400.raw.jsonl a closed live run
2600-open.raw.jsonl the live run; end = last line's seq + 1
2600-open.raw.jsonl the live run
Here 2400..2599 is a gap: the reader was away for two hundred events and
the stream reset. The suffix is the single chunk `2600-open`; the first
backwards page asks the server for `before=2600&after=2399&coalesce=true`,
and once a page comes back with `first == 2400` the suffix runs to seq 1.
Here 2400..2599 is a gap: the reader was away for two hundred events and the
stream reset. The suffix is the single chunk `2600-open`; the first backwards
page asks the server for `before=2600&after=2399&coalesce=true`, and once a
page comes back with `first == 2400` the suffix runs to seq 1.
Each `.jsonl` is one JSON object per line, oldest first, exactly as the
server sent it. No header, no index: coverage is in the name, order is the
@@ -437,76 +361,67 @@ file's, and the seq is in every line.
## What building it changed
Each of these contradicted something written above, and each was found by
running it rather than by reading it. The decisions themselves are amended
in place; this is the list of what moved, so that a reader who remembers the
first version knows what to re-read.
Each of these contradicted the plan, and each was found by running it rather
than by reading it. The decisions above are amended in place; this is what
moved, so a reader who remembers the first version knows what to re-read.
- **The probe's equality had a false premise** -- decision 3. The server did
- **The probe's equality had a false premise** (decision 3). The server did
not hand out the same line twice the same way. Fixed on the server.
- **A cached page starts anywhere inside the run** -- decision 5. Requiring
a chunk boundary would have made the cache save the opening window and
- **A cached page starts anywhere inside the run** (decision 5). Requiring a
chunk boundary would have made the cache save the opening window and
nothing else.
- **The opening window is stored by `append`, not by `storePage`.** The
sketch below had `storePage` grow a special case for "this page is the new
open chunk", decided by an implicit condition that a raw history page also
satisfies. Appending each line instead is the mechanism that already
exists, and the open chunk stays the one thing that grows.
sketch had `storePage` grow a special case for "this page is the new open
chunk", decided by an implicit condition a raw history page also satisfies.
Appending each line instead is the mechanism that already exists, and the
open chunk stays the one thing that grows.
- **Chunks are read backwards, in blocks, and never whole.** Every question
the cache is asked is about the newest end, and a live run reaches the size
of the conversation -- so reading a chunk to answer with eighty lines of it
of the conversation so reading a chunk to answer with eighty lines of it
is the cost the server's own reader was rewritten to stop paying, arriving
on the phone. Damage is therefore noticed when a read reaches it rather
than up front, which is the better time: what is not read cannot be wrong.
- **The stream waits for the opening effect's probe.** The screen lifts
`ready` before the probe returns -- that is the point of the cache -- so
`ready` before the probe returns that is the point of the cache so
`ready` stopped being the whole gate, and the stream loop asked the same
question a second time and raced its own answer. Two probes per warm open,
visible in the server's log.
- **`SessionCache` is synchronized.** The stream appends live events from
one IO thread while a reader scrolling back reads pages from another; the
open chunk's name, its end and its writer must never be seen
half-rotated.
- **`SessionCache` is synchronized.** The stream appends live events from one
IO thread while a reader scrolling back reads pages from another; the open
chunk's name, its end and its writer must never be seen half-rotated.
## What it cost, measured
On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of
505 events (three short exchanges and two 300-delta replies):
On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of 505
events (three short exchanges and two 300-delta replies):
- **Reopening it: one request, for one event.** The probe, and nothing else
-- including scrolling the whole conversation back to its first line. A
cold open of the same session is two requests and 100 events.
- **A reset after falling 300 events behind costs the gap and no more.**
The window arrived at seq 306, the phone held up to 202, and the first
- **Reopening it: one request, for one event.** The probe, and nothing else
including scrolling the whole conversation back to its first line. A cold
open of the same session is two requests and 100 events.
- **A reset after falling 300 events behind costs the gap and no more.** The
window arrived at seq 306, the phone held up to 202, and the first
backwards page asked `before=306&after=201` and came back with **four
coalesced rows** covering 202..305 -- against the 104 raw events an
unbounded page would have re-fetched and then thrown away.
coalesced rows** covering 202..305 against the 104 raw events an
unbounded page would have re-fetched and thrown away.
- **Every chunk is exactly what the server says for the range its name
claims**, checked line by line against `/transcript` for each chunk's own
`before`/`after`/`coalesce`, across a reset and a gap-fill.
- **Nothing about drawing changed**, which is what a cache must not do:
`transcript-bench.sh` before and after, same viewport content and the same
gestures, reported p50 16.9ms both times and the transcript's own draw
accounting at 0.33ms against 0.32ms.
`transcript-bench.sh` before and after, same viewport content and gestures,
p50 16.9ms both times and the transcript's own draw accounting at 0.33ms
against 0.32ms.
Still to measure, in real use rather than here: the size the cache reaches
against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever
what a reader waits on.
against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever what
a reader waits on.
## Open questions
- **The probe on every reconnect, not only on open?** Decision 3 probes
once per screen instance. A file replaced *while* the screen is open is
today's behaviour and not made worse, but the server-side check it
rejects would close it. Decide after measuring how often the probe's
round trip is what the reader waits on.
- **A reset arriving during an anchor restore** was an open worry when this
was written, and was measured and closed on 2026-09-04 (see "The reconnect
loop does not reproduce") before this landed. The cache makes the restore
cheaper again -- a warm one is now the probe and nothing else -- so it can
only have narrowed the window further. Worth re-measuring here only if a
reader reports the screen reconnecting on reopen.
- **Images.** `SessionImage` fetches bytes from the files route on draw;
they are not part of this cache and are re-downloaded per view. A
separate, simpler cache (a directory of refs, no ordering) if the
measurement above says the images are where the data goes.
- **The probe on every reconnect, not only on open?** A file replaced *while*
the screen is open is not made worse than it was, but the server-side check
decision 3 rejects would close it. Decide after measuring how often the
probe's round trip is what the reader waits on.
- **Images.** `SessionImage` fetches bytes from the files route on draw; they
are not part of this cache and are re-downloaded per view. A separate,
simpler cache (a directory of refs, no ordering) if the measurement above
says the images are where the data goes.
@@ -13,9 +13,8 @@ import androidx.compose.ui.text.style.TextDecoration
*
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
* Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from
* somewhere else, but the two are not one table and must not become one -- adding a syntax role to
* this list would silently move `ls`'s directory blue.
* Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one
* table -- adding a syntax role to this list would silently move `ls`'s directory blue.
*/
data class AnsiPalette(
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
@@ -30,19 +29,18 @@ data class AnsiPalette(
* What a tool printed, with its terminal styling applied and everything else taken out.
*
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
* verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of
* every green word. Stripping them all would be the other half-answer -- colour is often the whole
* of what a diff, a test run or a linter is saying.
* verbatim those are line noise in the middle of the thing being read. Stripping them all would be
* the other half-answer -- colour is often the whole of what a diff or a test run is saying.
*
* So the sequences that decide how text *looks* become spans, and every other one is dropped.
* Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript
* is a scrolling document, and "go to column 40" has no meaning here that is better than nothing.
* So the sequences that decide how text *looks* become spans, and every other one is dropped rather
* than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no
* meaning in a scrolling document.
*
* A carriage return is honoured the way a terminal honours it: what was written since the last line
* break is thrown away and the line starts again. That is what makes a progress bar show its final
* state rather than every state it passed through, which was tens of lines run together.
* state rather than every state it passed through.
*
* Not a composable, and the palette is a parameter: this can then be remembered against the text it
* Not a composable, and the palette is a parameter, so this can be remembered against the text it
* parsed rather than re-run on every recomposition of the card holding it.
*/
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
@@ -71,10 +69,9 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
if (final == 'm') sgr = sgr.apply(params, palette)
}
}
// A bare carriage return rewrites the line. One before a newline is the other half
// of a Windows line ending: it rewrites nothing, and it is dropped rather than kept,
// since that pair is one line break and the return itself would draw as a stray
// control character.
// A bare carriage return rewrites the line. One before a newline is the other half of a
// Windows line ending: it rewrites nothing, and it is dropped rather than kept, since
// that pair is one line break.
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
flush()
dropLine(runs)
@@ -82,8 +79,8 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
}
c == '\r' -> at++
// Everything printable, plus the two control characters that are layout rather than
// terminal commands. A stray bell or backspace goes for the same reason a cursor
// move does.
// terminal commands. A stray bell or backspace goes for the same reason a cursor move
// does.
c >= ' ' || c == '\n' || c == '\t' -> {
plain.append(c)
at++
@@ -129,9 +126,8 @@ private const val BELL = '\u0007'
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
*
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did
* not recognise would otherwise have its body printed as ordinary text, which is worse than the
* escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes
* (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones.
* not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI
* (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones.
*/
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
val next = text.getOrNull(at + 1) ?: return at + 1
@@ -140,9 +136,9 @@ private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Un
var end = at + 2
while (end < text.length && text[end] !in CSI_FINAL) end++
if (end >= text.length) {
// Cut off mid-sequence, which is what a stream that has not finished arriving
// looks like: drop the fragment rather than printing it, and the whole sequence
// arrives with the next delta.
// Cut off mid-sequence, which is what a stream that has not finished arriving looks
// like: drop the fragment rather than printing it, and the whole sequence arrives
// with the next delta.
text.length
} else {
onCsi(text.substring(at + 2, end), text[end])
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,9 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
@@ -9,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
@@ -19,6 +22,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed
import kotlinx.coroutines.Dispatchers
@@ -29,29 +33,39 @@ import kotlinx.coroutines.withContext
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them.
*
* Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views
* of the same backend, none of them a step down from another -- and what is left in this `when` is
* only what genuinely is a step down: one session, spawning one, and settings. A session's own
* settings are not among them: they are a dialog over the session, which is where the thing they
* change is.
* Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none
* of them a step down from another -- and what is left here is only what genuinely is a step down:
* one session, spawning one, and settings.
*/
private sealed class Screen {
data object Main : Screen()
/**
* One session, with the file explorer over it when [files] is set.
* One session, with the file explorer or a subagent transcript over it when set.
*
* The explorer is a layer on this screen rather than a screen of its own, so the session under
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and
* re-created on every return, refetching the transcript over the tunnel -- which is exactly the
* flip between "what did it change" and "what is it saying" that this feature exists for. The
* image viewer already made the same choice for the same reason.
* Both are layers on this screen rather than screens of their own, so the session under them
* stays composed: its event stream keeps flowing, its scroll position and draft stay put, and
* coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every
* return, refetching the transcript over the tunnel.
*/
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
data class Session(
val summary: SessionSummary,
val files: FilesTarget? = null,
val subagent: SubagentSummary? = null,
) : 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()
}
@@ -60,7 +74,7 @@ private sealed class Screen {
*
* The notification names an id and nothing else, so opening it means fetching the session first.
* [serial] tells two taps on the same session's notification apart, since they are two requests and
* would otherwise compare equal -- see MainActivity, which counts them.
* would otherwise compare equal.
*/
data class SessionOpenRequest(val sessionId: String, val serial: Int)
@@ -68,8 +82,8 @@ data class SessionOpenRequest(val sessionId: String, val serial: Int)
private data class FailedOpen(val request: SessionOpenRequest, val message: String)
/**
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity),
* re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re-
* reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
*
* [openRequest] is the session a notification tap asked for, likewise from MainActivity.
*
@@ -89,8 +103,8 @@ fun AppRoot(
// A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
// Bumped whenever another screen changes something the list shows, so
// returning to it refetches instead of showing a stale list.
// Bumped whenever another screen changes something the list shows, so returning to it
// refetches.
var reloadToken by remember { mutableIntStateOf(0) }
// Cleared by the session screen that attached it, not when a newer request arrives: a share
// must be attached exactly once, and only the screen that did it knows that it has.
@@ -104,9 +118,8 @@ fun AppRoot(
}
}
// A standing condition rather than a per-request failure, so it is
// stated once here instead of appended to every error that might be
// caused by it. Without this the app is simply unreachable and every
// A standing condition rather than a per-request failure, so it is stated once here instead of
// appended to every error it might cause. Without this the app is simply unreachable and every
// screen blames the server or the tunnel for it.
if (!localNetworkAllowed(context)) {
Text(
@@ -121,8 +134,8 @@ fun AppRoot(
val current = settings
if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR
// path lands in MainActivity and recomposes from the top.
// Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity
// and recomposes from the top.
Box(Modifier.imePadding()) {
SettingsScreen(
existing = null,
@@ -136,10 +149,9 @@ fun AppRoot(
return
}
// The one way back, whichever screen is showing and whether it was
// reached by the system back gesture or a screen's own Back button.
// Every leaf screen can have changed something the list shows, so it
// always refetches.
// The one way back, whichever screen is showing and whether it was reached by the system back
// gesture or a screen's own Back button. Every leaf screen can have changed something the list
// shows, so it always refetches.
val goToMain = {
reloadToken++
screen = Screen.Main
@@ -149,8 +161,8 @@ fun AppRoot(
}
// Turning a notification into the screen it points at. The id has to be resolved to a session
// first, because that is what SessionScreen is given -- and unlike a list row, which is a
// snapshot the list already fetched, there is nothing here to seed it from.
// first, because that is what SessionScreen is given -- and unlike a list row, there is nothing
// here to seed it from.
//
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
// an app that opens to the session list with no explanation looks like the tap missed.
@@ -180,12 +192,11 @@ fun AppRoot(
)
}
// Every screen but the session takes the keyboard as bottom padding here. The session
// screen deliberately does not: resizing a whole screen on every frame of the keyboard
// animation is the cost that made it lag, so it moves only its composer and transcript --
// see the layout note in SessionScreen.
// Every screen but the session takes the keyboard as bottom padding here. The session screen
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
// the cost that made it lag, so it moves only its composer and transcript.
when (val here = screen) {
is Screen.Main ->
Screen.Main ->
Box(Modifier.imePadding()) {
MainScreen(
settings = current,
@@ -198,28 +209,123 @@ fun AppRoot(
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
)
}
is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this
// one showing other rows. SessionScreen remembers a transcript, an open event stream, a
// draft and a scroll position, and without the key Compose keeps all of it across the
// change and merges two conversations -- which crashes the list on the first duplicate
// row key. Only reachable since a notification can move straight from one session to
// another; every other way here passes through [Screen.Main], which disposes it anyway.
// one showing other rows. SessionScreen remembers a transcript, an open stream, a draft
// and a scroll position, and without the key Compose keeps all of it across the change
// and merges two conversations -- which crashes the list on the first duplicate row
// key. Only reachable since a notification can move straight from one session to
// another.
key(here.summary.id) {
// A Box so the explorer can be drawn *over* the session rather than instead of
// it; the session stays composed underneath. No imePadding here, for the reason
// above -- the explorer adds its own, since it has a text field.
// A Box so the explorer can be drawn *over* the session rather than instead of it.
// No imePadding here, for the reason above -- the explorer adds its own.
Box {
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToMain,
onFiles = { screen = here.copy(files = it) },
share = share,
onShareTaken = { share = null },
)
val fileLinkHandler = rememberFileLinkHandler { path ->
screen = here.copy(files = here.summary.filesTarget(path))
}
Box(
Modifier.then(
if (here.subagent != null || here.files != null)
Modifier.clearAndSetSemantics {}
else Modifier
)
) {
// How much background work the session has, from the one subscription
// to its events the screen below holds. Here because the panel and that
// screen both draw it, and must draw the same number.
var backgroundTasks by
remember(here.summary.id) {
mutableIntStateOf(here.summary.backgroundTasks)
}
// 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
// platform asks first, and it steps back inside itself before closing.
here.files?.let { target ->
@@ -231,6 +337,15 @@ fun AppRoot(
}
}
}
is Screen.ProviderSettings ->
Box(Modifier.imePadding()) {
ProviderScreen(
settings = current,
machineId = here.machineId,
provider = here.provider,
onBack = goToMain,
)
}
is Screen.Spawn ->
Box(Modifier.imePadding()) {
SpawnScreen(
@@ -257,8 +372,7 @@ fun AppRoot(
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
// the activity puts around this, and that Box paints in the order it was given. A session
// wanting attention is not a fact about the page somebody happens to be on, so it is not the
// page's job to leave room for it. Tapping one is the same act as tapping a notification, so
// it goes through the same `open`, failure dialog included.
// wanting attention is not a fact about the page somebody happens to be on. Tapping one is the
// same act as tapping a notification, so it goes through the same `open`.
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
}
@@ -20,7 +20,6 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -41,13 +40,12 @@ data class QuestionAnswer(val questionId: String, val answers: List<String>)
* What the reader has settled on for one question, before any of it is sent.
*
* Held here rather than inferred from the transcript, which is what made picking an option feel
* broken: the mark used to appear only when the answer had crossed the tunnel, been recorded and
* come back as an event, so on a phone the card sat unchanged for most of a second after a tap and
* the natural response was to tap again.
* broken: the mark used to appear only when the answer had crossed the tunnel and come back as an
* event, so the card sat unchanged for most of a second after a tap.
*
* Picked options and typed words are one field each because they are alternatives rather than
* parts: answering in the reader's own words is the case no option covers, so typing puts the picks
* away and picking puts the words away, and there is never a draft that means two things.
* parts: typing puts the picks away and picking puts the words away, so there is never a draft that
* means two things.
*/
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
val settled: Boolean
@@ -65,29 +63,26 @@ data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
/**
* Every question one tool call is waiting on, one at a time.
*
* All of it comes from the question events themselves -- what each option means, what picking it
* would produce, whether several may be picked at once. None of it is read out of the call's own
* All of it comes from the question events themselves. None of it is read out of the call's own
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
* app, where no other provider can reach it and where it drifts the first time the schema moves.
*
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
* three questions with four options and a description each is several screens tall, so the reader
* scrolls past the question they are answering to reach the button that sends it, and never sees
* the whole of any one of them. Paged, each question is a screen and the count says how many are
* left -- which is also what makes "not all of them are answered" something the reader can act on
* rather than something to go hunting for.
* scrolls past the question they are answering to reach the button that sends it. Paged, each
* question is a screen and the count says how many are left.
*
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
* asked them together and is waiting on all of them, and sending each as it was tapped meant the
* reader could not change their mind about the first after reading the third.
* asked them together, and sending each as it was tapped meant the reader could not change their
* mind about the first after reading the third.
*/
@Composable
fun AskUserQuestionBody(
asks: List<TranscriptItem.QuestionCard>,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) {
// Seeded from what was already answered, so a card the reader comes back to shows their
// answers rather than an empty draft over them.
// Seeded from what was already answered, so a card the reader comes back to shows their answers
// rather than an empty draft over them.
var drafts by
remember(asks.map { it.id }) {
mutableStateOf(
@@ -125,7 +120,7 @@ fun AskUserQuestionBody(
modifier = Modifier.weight(1f),
)
// Disabled at the ends rather than absent, so the pair keeps its place and the
// reader can see that there is nothing further that way.
// reader can see there is nothing further that way.
MarkButton("Previous question", { at-- }, enabled = at > 0) {
Chevron(Pointing.Left, colour = LocalContentColor.current)
}
@@ -143,7 +138,7 @@ fun AskUserQuestionBody(
if (outstanding.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
// Greyed until every question has an answer, because the tool is waiting on all of
// them: a submit that sent two of three would leave the third one asked and the card
// them: a submit that sent two of three would leave the third asked and the card
// looking dealt with.
val ready = outstanding.all { drafts[it.id]?.settled == true }
Button(
@@ -155,8 +150,8 @@ fun AskUserQuestionBody(
}
) {
// Back to a button whatever happened. A refusal is reported by the screen
// around this, and the draft is still here to send again -- a spinner
// that never stops would be the only sign of a failure this card cannot
// around this, and the draft is still here to send again -- a spinner that
// never stops would be the only sign of a failure this card cannot
// describe.
sending = false
}
@@ -165,8 +160,8 @@ fun AskUserQuestionBody(
modifier = Modifier.fillMaxWidth(),
) {
if (sending) {
// In the button rather than beside it, so the row does not change height at
// the moment it is pressed.
// In the button rather than beside it, so the row does not change height at the
// moment it is pressed.
CircularProgressIndicator(
Modifier.height(18.dp).width(18.dp),
strokeWidth = 2.dp,
@@ -186,8 +181,7 @@ fun AskUserQuestionBody(
* One question: what is being asked, what can be answered, and what was.
*
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
* when nothing did. A question is the same thing either way, and two renderings of it would be two
* places for an answer to go missing.
* when nothing did. Two renderings of it would be two places for an answer to go missing.
*
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
* sends anything. An answered question ignores both and draws what was answered.
@@ -214,10 +208,10 @@ fun AskedQuestion(
// replacing them with a line repeating it. The options are what the question *was*, and
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
// very little without the three it was chosen over. Marked in the same purple that says
// "picked" while the question is still open, so it is one appearance learned once.
// "picked" while the question is open, so it is one appearance learned once.
val answered = ask.answers.isNotEmpty()
// What is marked: what was answered once there is an answer, and what the finger has
// chosen until then.
// What is marked: what was answered once there is an answer, and what the finger has chosen
// until then.
val marked = if (answered) ask.answers.toSet() else draft.picked
// Null once the question is answered: the options stay and stop being pressable.
val onPick: ((String) -> Unit)? =
@@ -233,9 +227,9 @@ fun AskedQuestion(
}
}
}
// What was answered in the reader's own words, which no option can mark -- see
// [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered
// by picking says it by the mark alone.
// What was answered in the reader's own words, which no option can mark. Only ever the
// answers that match nothing offered, so a question answered by picking says it by the
// mark.
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
if (inWords.isNotEmpty()) {
Text(
@@ -252,10 +246,8 @@ fun AskedQuestion(
}
/**
* [label] added to, or taken out of, what [draft] has picked.
*
* A single-answer question replaces rather than accumulates, and either way picking puts any typed
* words away -- see [Draft].
* [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces
* rather than accumulates, and either way picking puts any typed words away -- see [Draft].
*/
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
when {
@@ -269,8 +261,7 @@ private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
*
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
* should have been, which is the failure a tint step routinely produces on a dark theme. A border
* is one cue and it is unambiguous.
* should have been. A border is one cue and it is unambiguous.
*/
@Composable
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
@@ -324,8 +315,8 @@ private fun Preview(preview: String) {
preview,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines
// of the thing being previewed.
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of
// the thing being previewed.
softWrap = false,
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
)
@@ -336,20 +327,18 @@ private fun Preview(preview: String) {
* The choice the asker always leaves open, and the app has to as well.
*
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
* rather than pick. Leaving it out narrows a question that was never that narrow, and the reader
* cannot tell that it was ever open.
* rather than pick. Leaving it out narrows a question that was never that narrow.
*/
@Composable
private fun OtherAnswer(text: String, onText: (String) -> Unit) {
// 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
// like the one that finishes it.
OutlinedTextField(
LabelledField(
label = "Other",
value = text,
onValueChange = onText,
label = { Text("Other") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
modifier = Modifier.padding(top = 8.dp),
)
}
@@ -358,8 +347,7 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) {
*
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
* with four options showed the first one or two and dropped the rest off the side of the screen.
* That does not read as a bug: it reads as those having been the only choices, which is the worst
* way for a list of choices to be wrong.
* That reads as those having been the only choices.
*/
@Composable
fun AnswerOptions(
@@ -379,8 +367,8 @@ fun AnswerOptions(
OutlinedButton(
onClick = { onPick?.invoke(option.label) },
// Disabled rather than removed, so an answered question still shows what it
// offered. Material dims a disabled button's own border and label, which would
// take the mark with it -- both are stated here instead.
// offered. Material dims a disabled button's own border and label, which would take
// the mark with it -- both are stated here instead.
enabled = onPick != null,
border =
BorderStroke(
@@ -11,6 +11,30 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
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
* from the list it writes -- rather than a file kept under its own name. Mirrors the server's
@@ -28,8 +52,8 @@ fun attachmentName(ref: String): String = ref.substringAfter('-', ref)
/**
* One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A
* file is not fetched -- there is nothing on this phone to open a trace or a log with -- so the
* name is the whole of it.
* file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all
* of it.
*/
@Composable
fun Attachment(
@@ -50,8 +74,7 @@ fun Attachment(
/**
* A file's name, one line, in the face names are read in. Overlong names lose their middle: a name
* is identified by both ends -- what it is at the front, what kind at the back -- and either
* ellipsis alone takes away one of them.
* is identified by both ends -- what it is at the front, what kind at the back.
*/
@Composable
fun FileName(name: String, modifier: Modifier = Modifier) {
@@ -20,10 +20,8 @@ import kotlin.math.max
* to be either thrown away or rejected -- which is what "sending an image is broken" was.
*
* Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the
* expensive part of this on a phone is the upload, not the decode. What the limit *is* comes from
* the server, per session -- see `DriverKind::max_image_edge` -- because that is where a provider's
* requirements are known, and a phone that carried its own copy of them would be a second place to
* update when one changes.
* expensive part on a phone is the upload, not the decode. What the limit *is* comes from the
* server, per session, because that is where a provider's requirements are known.
*/
suspend fun uploadPickedImage(
context: Context,
@@ -40,6 +38,11 @@ suspend fun 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
* 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(
context: Context,
@@ -47,23 +50,28 @@ suspend fun uploadPicked(
sessionId: String,
uri: Uri,
maxEdge: Int?,
images: ImageSupport,
): String {
val resolver = context.contentResolver
val mime = resolver.getType(uri)
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)
}
// Opened before the request starts, so a provider that refuses says so here and not from
// inside the connection; then streamed, since a trace or a log is bigger than this process
// should hold at once.
// Opened before the request starts, so a provider that refuses says so here and not from inside
// the connection; then streamed, since a trace is bigger than this process should hold at once.
val source = openSource(resolver, uri)
val name = displayName(resolver, uri)
return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out ->
try {
source.use { it.copyTo(out, COPY_BUFFER) }
} catch (e: java.io.IOException) {
// Either side of the copy can fail; the message names the file, which is the
// part the reader can do something about.
// Either side of the copy can fail; the message names the file, which is the part the
// reader can do something about.
throw ApiException("couldn't send $name: ${e.message}", cause = e)
}
}
@@ -76,7 +84,7 @@ private const val COPY_BUFFER = 64 * 1024
*
* A share arrives with whatever access the other app granted, and a provider that refuses says so
* with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both
* are things the reader can act on, so neither is left to end the process.
* are things the reader can act on.
*/
private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream =
try {
@@ -110,9 +118,9 @@ private fun displayName(resolver: ContentResolver, uri: Uri): String {
/**
* The bytes to upload and what they are, scaled down only if they need to be.
*
* An image already inside the limit is uploaded exactly as it came, rather than decoded and
* re-encoded to the same size: a round trip through JPEG loses a little every time, and there is
* nothing to gain from it. This is also the path a provider with no limit always takes.
* An image already inside the limit is uploaded exactly as it came, rather than decoded and re-
* encoded to the same size: a round trip through JPEG loses a little every time. This is also the
* path a provider with no limit always takes.
*/
private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> {
val resolver = context.contentResolver
@@ -128,9 +136,9 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteA
// decision it has no business making.
if (longest <= 0 || longest <= maxEdge) return original to mime
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding
// the full twelve megapixels only to shrink it is how this runs out of memory on the images
// it most needs to handle.
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding the
// full twelve megapixels only to shrink it is how this runs out of memory on the images it most
// needs to handle.
val decode =
BitmapFactory.Options().apply {
inSampleSize = Integer.highestOneBit(max(1, longest / maxEdge))
@@ -141,9 +149,8 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteA
val matrix = Matrix()
if (scale < 1f) matrix.postScale(scale, scale)
// The camera writes which way up the picture is into EXIF rather than rotating the pixels, and
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side, with
// nothing anywhere saying so. Applied to the same matrix as the scale, so it costs no second
// copy of the bitmap.
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side.
// Applied to the same matrix as the scale, so it costs no second copy of the bitmap.
matrix.postRotate(exifRotation(original))
val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
val out = ByteArrayOutputStream()
@@ -166,8 +173,8 @@ private fun exifRotation(bytes: ByteArray): Float =
else -> 0f
}
} catch (_: java.io.IOException) {
// No EXIF, or none this can read. Upright is the assumption every
// image without the tag is displayed under anyway.
// No EXIF, or none this can read. Upright is the assumption every image without the tag is
// displayed under anyway.
0f
}
@@ -0,0 +1,244 @@
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,25 +1,28 @@
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
// The composer's row of settings and pickers, and the menus they open. One file because the
// outline and the corner are one appearance: a control shaped like this opens a surface shaped
// like this, and a reader learns the pair once.
// The composer's row of settings and pickers, and the menus they open. One file because the outline
// and the corner are one appearance: a control shaped like this opens a surface shaped like this.
/**
* A bordered pill: a control that can be seen without being pressed.
*
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
* all until they are touched. Three bare words sitting under the message field read as a caption
* about the field rather than as three things to press, and the only way to find out otherwise was
* to press one. The outline says "control" without the weight of a filled button, which is reserved
* here for the two that act on the session (send, and start/stop).
* all until they are touched. Three bare words under the message field read as a caption about the
* field rather than as three things to press. The outline says "control" without the weight of a
* filled button, which is reserved for the two that act on the session.
*/
@Composable
fun BubbleButton(
@@ -32,8 +35,8 @@ fun BubbleButton(
onClick = onClick,
enabled = enabled,
shape = BubbleShape,
// A text button's padding rather than a filled button's 24dp: these sit three across
// under the message field, and the wider padding is what decides whether the row fits.
// A text button's padding rather than a filled button's 24dp: these sit three across under
// the message field, and the wider padding is what decides whether the row fits.
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier,
) {
@@ -48,7 +51,52 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
* The corner on a menu one of these opens.
*
* A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding
* ends that tall would bow its sides. This is the roundest corner that still leaves a straight edge
* beside a one-line option, which is the shortest menu here.
* ends that tall would bow its sides.
*/
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
/**
* 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
@@ -26,20 +26,16 @@ import androidx.compose.ui.unit.dp
* of the operation over it.
*
* One composable rather than a pattern each list repeats, because "this row is busy" has to look
* the same in the import list and the session list or the appearance becomes a per-screen dialect
* rather than something the reader learns once.
* the same in the import list and the session list or the appearance becomes a per-screen dialect.
*
* [label] names the operation and `null` means none is running. One parameter rather than a boolean
* beside a string, which can disagree: there is no such thing as busy with nothing happening. It is
* a *word* because a spinner alone cannot say which operation this is deleting and importing are
* different in kind, and losing a session to the wrong one is not recoverable by waiting.
* beside a string, which can disagree. It is a *word* because a spinner alone cannot say which
* operation this is -- deleting and importing are different in kind.
*
* It does **not** make the row inert; the caller disables its own click handling while it passes a
* label. That was the other way round at first an overlay consuming pointer events, so no caller
* had to remember — and it swallowed the drag along with the tap, which meant a list could not be
* scrolled while anything in it was busy. Consuming taps but not drags means re-deciding what a
* gesture is above the components that already decide it; disabling the click is the platform's own
* answer and leaves the scroll where it belongs.
* label. That was the other way round at first -- an overlay consuming pointer events -- and it
* swallowed the drag along with the tap, so a list could not be scrolled while anything in it was
* busy.
*/
@Composable
fun BusyItem(label: String?, content: @Composable () -> Unit) {
@@ -71,14 +67,12 @@ fun BusyItem(label: String?, content: @Composable () -> Unit) {
/**
* How an item looks while it is being acted on: darker, and nearly grey.
*
* Both, rather than either alone. Dimming by itself is what this app already used for a row on its
* way out, and it is the same cue as a disabled control, so a busy row read as one more thing that
* could not be tapped. Draining the colour is what says the row is *suspended* — the status word,
* the accent on a warning and everything else that means something by its colour stop meaning it
* for as long as the operation runs, which is exactly true: none of them is being kept up to date.
* Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a
* busy row read as one more thing that could not be tapped. Draining the colour is what says the
* row is *suspended* -- the status word and everything else that means something by its colour stop
* meaning it for as long as the operation runs, which is exactly true.
*
* Not all the way to grey. A row with no colour left is hard to find again in a list, and the
* reader is watching this one.
* Not all the way to grey: a row with no colour left is hard to find again in a list.
*/
private fun Modifier.busy(busy: Boolean): Modifier =
if (!busy) this
@@ -27,13 +27,10 @@ enum class Pointing {
*
* One composable for all four directions rather than one per axis that differ by which coordinate
* gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one
* direction. The shape is written once in its own coordinates, where x runs across the opening and
* y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto
* the box.
* direction. The shape is written once in its own coordinates, and [Pointing] is only a table of
* how those map onto the box.
*
* It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole
* of what assistive technology has to go on, and it is also the answer to "what was that arrow for"
* six months from now.
* It draws no label of its own, so every caller owes it a `contentDescription`.
*/
@Composable
fun Chevron(
@@ -30,14 +30,12 @@ import org.intellij.markdown.ast.getTextInNode
* sits on, scrolling sideways rather than wrapping.
*
* The renderer's own fence drew the same block in plain text. The scanner that colours a tool
* call's command colours a reply's code the same way, through [highlighted] and one palette, so a
* `kotlin` fence and the Kotlin a tool wrote are the same colours. A fence in a language [scan] has
* no rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and
* plain is what the reader would have seen before.
* call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool
* wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than
* wrongly coloured.
*
* Finding the code is still the library's: which children of the node are the fence markers, the
* language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence]
* hands out the code and the language and leaves the drawing to the block it is given.
* language word and the code between them is its knowledge of the parser.
*/
@Composable
fun CodeFence(
@@ -67,14 +65,12 @@ fun CodeBlock(
/**
* The code inside a fence or indented block, and the highlighter's language for its info word.
*
* Which children of the node are the fence markers, the language word and the code between them is
* the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called:
* that one is a composable, and the whole point of this function is that [warm] can run it on a
* background thread and highlight the same string the drawing will ask for. Two extractions would
* be two keys, and the warmed answer would be silently missed at every fence.
* Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and
* the whole point here is that [warm] can run this on a background thread and highlight the same
* string the drawing will ask for. Two extractions would be two keys, and the warmed answer would
* be silently missed at every fence.
*
* Null for a fence too short to hold anything -- an unterminated one still arriving, which the
* library skips as invalid.
* Null for a fence too short to hold anything -- an unterminated one still arriving.
*/
fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
val word =
@@ -97,7 +93,6 @@ fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
*
* The renderer's own block, less what nothing here needs: the same background, corner, padding and
* sideways scroll, without the shadow, the border and the empty pointer handler it also carried.
* The vertical margin is the renderer's too, kept so a reply's fences sit where they always have.
*/
@Composable
private fun CodeBlockText(
@@ -117,8 +112,7 @@ private fun CodeBlockText(
.semantics { isTraversalGroup = true }
) {
BasicText(
// No language while the block is still being written, which is what draws it plain;
// see [MarkdownRoot]'s `streaming`.
// No language while the block is still being written, which is what draws it plain.
replies.highlighted(code, language.takeUnless { streaming }),
style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
@@ -141,14 +135,12 @@ fun fenceLanguage(name: String?): Language? =
* The highlighter's language for a *file*, from its name.
*
* The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people
* write after the backticks -- `kt`, `rs`, `py` -- because the extension is as often what gets
* written there as the language's name. One table rather than two, so a language added for fences
* is a language added for files and neither can be the one somebody forgot.
* write after the backticks. One table rather than two, so a language added for fences is a
* language added for files and neither can be the one somebody forgot.
*
* The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin and
* `Cargo.toml` TOML. A leading dot is not one: `.bashrc` has no extension, it has a name that
* starts with a dot, and reading `bashrc` as an extension would look up a word no table has. A name
* with no dot at all -- `Makefile`, `LICENSE` -- is likewise null, and null is drawn plain.
* The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A
* leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name
* with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain.
*/
fun fileLanguage(name: String): Language? {
val dot = name.lastIndexOf('.')
@@ -168,6 +160,7 @@ private val FENCE_LANGUAGES: Map<String, Language> =
"shell" to Language.SHELL,
"zsh" to Language.SHELL,
"console" to Language.SHELL,
"diff" to Language.DIFF,
"python" to Language.PYTHON,
"py" to Language.PYTHON,
"javascript" to Language.JAVASCRIPT,
@@ -206,10 +199,9 @@ private val FENCE_LANGUAGES: Map<String, Language> =
)
/**
* Every fence in [parse], as the code and language [highlight] will be asked for.
*
* Walks the whole tree rather than the top level: a fence inside a list item or a quote is drawn
* the same way and costs the same to lex.
* Every fence in [parse], as the code and language [highlight] will be asked for. Walks the whole
* tree rather than the top level: a fence inside a list item or a quote is drawn the same way and
* costs the same to lex.
*/
fun fences(parse: State): List<Pair<String, Language?>> {
val success = parse as? State.Success ?: return emptyList()
@@ -25,8 +25,7 @@ import androidx.compose.ui.unit.dp
* These are the two this app understands, and understanding them is what lets it show them: a
* suggestion while one is being typed, a name in the settings screen that sends one, and a bubble
* that stays up while the session is too busy to run it. Anything else beginning with "/" is passed
* through to whatever runs the session, because a dialect's own vocabulary is its own and grows
* without this list -- it just arrives unannounced and unexplained.
* through, because a dialect's own vocabulary grows without this list.
*/
data class SessionCommand(
/** With the slash, as it is typed and as it is sent. */
@@ -90,8 +89,8 @@ fun CommandSuggestions(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
// The command in the colour commands are, so the suggestion and the
// bubble it becomes are visibly the same thing.
// The command in the colour commands are, so the suggestion and the bubble
// it becomes are visibly the same thing.
if (command.argument == null) command.name
else "${command.name} <${command.argument}>",
style = MaterialTheme.typography.titleSmall,
@@ -117,8 +116,7 @@ fun CommandSuggestions(
* anything appearing here.
*
* [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes
* and reads as having been missed.
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes.
*/
@Composable
fun CommandBubble(text: String, waiting: Boolean = false) {
@@ -128,8 +126,8 @@ fun CommandBubble(text: String, waiting: Boolean = false) {
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
) {
Column(Modifier.padding(12.dp)) {
// Stated beside the fill rather than inherited: a semantic colour has to carry
// its own contrast, because the surface under it will not change to rescue it.
// Stated beside the fill rather than inherited: a semantic colour has to carry its
// own contrast, because the surface under it will not change to rescue it.
Text(text, color = MaterialTheme.colorScheme.inverseOnSurface)
if (waiting) {
Spacer(Modifier.height(6.dp))
@@ -7,12 +7,10 @@ import androidx.compose.ui.Modifier
* The mark a compaction leaves in the transcript.
*
* A divider rather than something anybody said: everything above it is out of the session's context
* now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it
* is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which
* a clear also uses, so the two marks cannot drift apart.
* now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider],
* which a clear also uses, so the two marks cannot drift apart.
*
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it,
* which is the same thing the status line says while the compaction runs.
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it.
*/
@Composable
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
@@ -23,9 +21,8 @@ fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifi
* What to say about a compaction: the two sizes, and nothing else.
*
* The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer
* to why the wait was worth it -- and they are all this says, because a divider is read in passing.
* When they were not reported this says only that a compaction happened, rather than filling in a
* plausible number or explaining at length what was missing.
* to why the wait was worth it. When they were not reported this says only that a compaction
* happened, rather than filling in a plausible number.
*/
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
val pre = item.preTokens
@@ -41,8 +38,8 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String {
* A token count as a reader reads one.
*
* Shared with the status row rather than formatted at each: the divider and the row report the same
* quantity about the same moment, and one of them grouping its thousands while the other did not
* read as two different measurements.
* quantity about the same moment, and one grouping its thousands while the other did not read as
* two different measurements.
*/
fun tokens(count: Long): String = "%,d".format(count)
@@ -50,15 +47,12 @@ fun tokens(count: Long): String = "%,d".format(count)
* What the working indicator says while a compaction is running.
*
* Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a
* compaction has begun and then says nothing until it has finished, so any bar, percentage or
* estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been
* going forty seconds is what a reader actually wants -- it is the difference between waiting and
* going to look at why.
* compaction has begun and then says nothing until it has finished, so any bar or estimate here
* would be this screen's guess wearing a measurement's clothes.
*
* [seconds] is null when this device did not see the compaction start, which is what opening a
* session that is already compacting looks like. That case says only "compacting": no number is the
* honest answer, and a number counted from the moment the screen opened would be wrong in the
* direction that matters, since a compaction somebody is asking about is a long one.
* session that is already compacting looks like. That case says only "compacting": a number counted
* from the moment the screen opened would be wrong in the direction that matters.
*/
fun compactingLabel(seconds: Long?): String =
when {
@@ -66,3 +60,23 @@ fun compactingLabel(seconds: Long?): String =
seconds < 60 -> "compacting ${seconds}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,9 @@ import java.util.Locale
* The last crash, kept so the debug button can hand it over.
*
* The alternative is asking somebody to reproduce a crash with the phone plugged into a computer
* and `logcat` running, which is the one thing nobody has set up at the moment it happens -- and a
* crash report that arrives a day later, without the stack, is a guess. This costs one file write
* on a process that is already dying, and it turns "it crashes when I open that chat" into the
* frame it crashed in.
* and `logcat` running, which is the one thing nobody has set up at the moment it happens. This
* costs one file write on a process that is already dying, and it turns "it crashes when I open
* that chat" into the frame it crashed in.
*
* Kept until it is read rather than cleared on the next launch: the app restarts before anybody can
* ask about it, so a log that lives for one session is a log that is never read.
@@ -26,8 +25,7 @@ private const val CRASH_FILE = "last-crash.txt"
* How much of a stack is kept.
*
* This is pasted into a conversation, so it has a budget like any other output written for a
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing, so
* what gets cut is the part nobody reads.
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing.
*/
private const val CRASH_LIMIT = 4000
@@ -35,8 +33,7 @@ private const val CRASH_LIMIT = 4000
* Records uncaught exceptions, then lets the platform do what it was going to do.
*
* Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and
* ends the process, and an app that swallows that instead sits there in an unknown state. This only
* adds a witness.
* ends the process, and an app that swallows that instead sits there in an unknown state.
*/
fun installCrashLog(context: Context) {
val app = context.applicationContext
@@ -12,10 +12,9 @@ import java.util.concurrent.atomic.AtomicLong
*
* Here because the emulator cannot answer the question this is for. Its own scroll sits at the same
* frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost
* is under the floor of what it can measure, and a frame number taken in it says nothing about a
* 120Hz phone. Counts do not have that problem: how many times a row was composed, or a reply
* parsed, is the same number on any machine, and it is the number that says whether the work is
* proportional to what is on screen or to everything ever loaded.
* is under the floor of what it can measure. Counts do not have that problem: how many times a row
* was composed, or a reply parsed, is the same number on any machine, and it is the number that
* says whether the work is proportional to what is on screen or to everything ever loaded.
*
* Always on rather than behind a build flag. What is measured is an atomic increment on paths that
* already allocate lists and parse markdown, and a counter that is only compiled into the build
@@ -90,14 +89,12 @@ object DebugStats {
*
* The draw phase is where Compose's measurement lands as well as its recording -- the platform
* calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three
* different things is high. The transcript times its own measure, its own placement and its own
* recording, and this is the subtraction that was otherwise done by hand in a conversation every
* time a report arrived. What is left over is the framework's per-frame bookkeeping after a layout,
* which grows with how many nodes are alive rather than with how many are on screen.
* different things is high. The transcript times its own measure, placement and recording, and this
* is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout,
* which grows with how many nodes are alive rather than how many are on screen.
*
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
* are not themselves per-frame -- a measurement happens on the frames that need one -- so these are
* shares of an average frame, not a claim about any particular one.
* are not themselves per-frame, so these are shares of an average frame.
*/
fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
if (frames == 0 || drawNanos == 0L) return emptyList()
@@ -122,8 +119,7 @@ fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
* frames went, and what the app did to produce them.
*
* Written for somebody to paste into a conversation, so it is plain text with the units on every
* number -- a report whose reader has to ask what the columns mean costs another round trip, and
* the whole point of it is to save one.
* number -- a report whose reader has to ask what the columns mean costs another round trip.
*/
fun debugReport(
device: String,
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* A line across the transcript saying what left the session's context.
@@ -21,11 +25,7 @@ import androidx.compose.ui.unit.dp
* reader scrolling back, both mean "the session no longer has what is above this", and which of the
* two it was is said by the words and the colour.
*
* The rules take [color] too, so the whole divider reads as one mark of one kind rather than a
* coloured phrase sitting in an unrelated grey line.
*
* Written once here rather than styled at each of them, so the two cannot drift into looking like
* different kinds of thing.
* The rules take [color] too, so the whole divider reads as one mark of one kind.
*/
@Composable
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
@@ -40,15 +40,73 @@ 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.
*
* Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
* compaction it summarises nothing and measures nothing, so there is nothing to report but the
* fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is
* why this does not say it.
* compaction it summarises nothing and measures nothing. Everything above stays on screen and stays
* scrollable -- the reader can see that, which is why this does not say it.
*/
@Composable
fun ClearedRow(modifier: Modifier = Modifier) {
TranscriptDivider("Context cleared", clearedColor, modifier)
}
/**
* The mark running out of quota leaves.
*
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
* place: colour by consequence, so "there is nothing left to spend" is learned once.
*
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
* which is not a promise about when the session picks back up. A limit the session was told no
* reset time for says nothing about one -- that state has its own words rather than a plausible
* number.
*/
@Composable
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
}
/**
* What the row says. Split out so the wording is testable without a screen, since the two states it
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
* that reads the same when it goes wrong.
*
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
*/
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
val at = resetsAt?.let {
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone)
.format(Instant.ofEpochSecond(it.toLong()))
} catch (_: Exception) {
null
}
}
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
}
@@ -10,12 +10,11 @@ private const val DRAFTS = "session-drafts"
*
* On this device rather than on the backend, which is where this app otherwise keeps state so that
* every device sees it. A draft is the case that rule is not about: it is the contents of a text
* box on the phone somebody is holding, written on every keystroke, and half a sentence surfacing
* on another device would be a surprise rather than a convenience. What has been *sent* is the
* server's, and that is the part which has to outlive this phone.
* box on the phone somebody is holding, and half a sentence surfacing on another device would be a
* surprise. What has been *sent* is the server's.
*
* Kept per session id, because the thing being typed belongs to the conversation it is aimed at:
* one shared box would hand a message meant for one session to whichever was opened next.
* Kept per session id: one shared box would hand a message meant for one session to whichever was
* opened next.
*/
fun loadDraft(context: Context, sessionId: String): String =
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty()
@@ -23,11 +22,9 @@ fun loadDraft(context: Context, sessionId: String): String =
/**
* Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep.
*
* The path out is emptying the box, which is what sending does -- so a sent message removes its own
* entry and nothing accumulates for a session in ordinary use. A session *deleted* while it held a
* draft does leave its key behind: pruning those means a pass over the live session list, which
* this file would otherwise have no reason to know about, and the residue is a few bytes per
* session ever abandoned mid-sentence. That is a trade rather than an oversight.
* The path out is emptying the box, which is what sending does. A session *deleted* while it held a
* draft does leave its key behind: pruning those means a pass over the live session list, and the
* residue is a few bytes per session ever abandoned mid-sentence.
*/
fun saveDraft(context: Context, sessionId: String, text: String) {
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit {
@@ -5,13 +5,12 @@ package com.example.aiapp
*
* A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two
* halves, because a short span and a long one are read for different things. Under a minute the
* question is "roughly how long", so only the largest unit is shown and a fraction of it carries
* the rest -- `2.5s`, `30ms`. At a minute or more the question is "how long exactly", so every unit
* that has something in it is written out -- `5d 12h 4m`. Units that are empty are left out rather
* than written as zero, since the labels say which is which and `5d 0h 4m` is only longer.
* question is "roughly how long", so only the largest unit is shown and a fraction carries the rest
* -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something
* in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero.
*
* Sub-second precision is dropped past a minute: nothing that takes days is measured in
* milliseconds, and carrying them would make the common case the widest one.
* milliseconds.
*/
fun formatMillis(ms: Long): String {
if (ms < 0) return "-" + formatMillis(-ms)
@@ -12,9 +12,9 @@ private const val RESET_EVENT = "reset"
*
* The connection and its framing belong to [Sse]; what stays here is what this stream's frames
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor. See SessionScreen.
* saw as the new cursor.
*/
class EventStream(settings: ServerSettings, private val sessionId: String) {
class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
private val stream = Sse(settings)
fun close() = stream.close()
@@ -24,20 +24,19 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
*
* [onReset] fires when the server answers that the cursor is too far behind to continue from:
* everything already displayed is stale and the events that follow are a fresh window, so the
* caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It
* arrives before those events, so a caller that clears on it stays in order.
* caller drops what it holds and rebuilds. It arrives before those events, so a caller that
* clears on it stays in order.
*/
fun run(
after: Long,
onOpen: () -> Unit,
onReset: () -> Unit,
// The frame's own text as well as the event parsed from it: the transcript cache stores
// the one and the screen folds the other, and they have to be the same line.
// The frame's own text as well as the event parsed from it: the transcript cache stores the
// one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit,
) {
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name, so this is one or
// the other.
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name.
if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
}
@@ -2,10 +2,9 @@ package com.example.aiapp
import org.json.JSONObject
// The common event model, mirrored from server/src/session/driver.rs --
// the app renders purely from this stream (replayed from the transcript by
// cursor, then live), so there is no separate "load history" shape to keep
// in sync with it.
// The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from
// this stream (replayed from the transcript by cursor, then live), so there is no separate "load
// history" shape to keep in sync with it.
/** One transcript line: the event plus its resume cursor and time. */
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
@@ -13,8 +12,7 @@ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
/**
* One choice offered in answer to a question.
*
* More than a label because the reader is deciding rather than confirming: what an option means,
* and what picking it would produce, are the things that decide it. Both are absent on a
* More than a label because the reader is deciding rather than confirming. Both are absent on a
* permission, whose Allow and Deny mean exactly what they say.
*/
data class QuestionOption(val label: String, val description: String?, val preview: String?)
@@ -30,13 +28,12 @@ sealed class SessionEvent {
*/
val id: String?,
/**
* What was attached to it, by the ref the files route serves: images, and since 2026-09-03
* any file, told apart by [isImageRef].
* What was attached to it, by the ref the files route serves: images, and any file, told
* apart by [isImageRef].
*
* On the message rather than beside it: these arrived as separate image events until
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
* it, and left this app deciding from adjacency alone which message an image went with --
* something the sender knew and could simply have said.
* it, and left this app deciding from adjacency which message an image went with.
*/
val attachments: List<String>,
) : SessionEvent()
@@ -45,11 +42,10 @@ sealed class SessionEvent {
* A message the server has accepted and the session has not read yet.
*
* From the server, not from this app's memory of what it sent. The pending bubble used to be
* screen state, so leaving the session or restarting the app drew nothing waiting while the
* message was still queued -- and nothing waiting is what "there is nothing" looks like.
* screen state, so leaving the session drew nothing waiting while the message was still queued
* -- and nothing waiting is what "there is nothing" looks like.
*
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
* [CommandSent].
* Resolved by the [UserMessage] carrying the same id.
*/
data class MessageQueued(val id: String, val text: String, val attachments: List<String>) :
SessionEvent()
@@ -59,13 +55,30 @@ sealed class SessionEvent {
*
* Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects
* replays both, and without this one it would put back a bubble for a message that is never
* coming -- with nothing left to resolve it, since the [UserMessage] that normally does is
* exactly what was cancelled.
* coming.
*/
data class MessageDropped(val id: String) : SessionEvent()
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 ToolUpdate(val id: String, val output: String) : SessionEvent()
@@ -108,51 +121,96 @@ sealed class SessionEvent {
*
* The live Claude Code path only learns a turn was somebody else's when the turn ends, so
* the event arrives below everything it caused; this is what puts it back above it. Null
* for a message read out of a session file, which is already in the right place, and for
* one that started no turn. See the server's `Event::PeerMessage`.
* for a message read out of a session file, and for one that started no turn.
*/
val turnStart: Long? = null,
) : SessionEvent()
/**
* A command the session was asked to run on itself and cannot run yet.
* 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.
*
* Resolved by [CommandSent] with the same id. A command that ran straight away has only that
* one, so nothing here ever draws a bubble that resolves in the same frame.
* [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
* [CommandSent] with the same id; a command that ran straight away has only that one.
*/
data class CommandQueued(val id: String, val text: String) : SessionEvent()
/** The same command, handed to the session. */
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()
/**
* What the session is set to, as the session itself reports it.
*
* Either field alone: the two are confirmed separately and by different things. Asking for a
* change is not having one, so this -- not the request -- is what the pickers show.
* Either field alone: the two are confirmed separately. Asking for a change is not having one,
* so this -- not the request -- is what the pickers show.
*/
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/**
* 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.
*
* [context] is prompt plus both cache figures, measured by the backend from the turn's own
* usage. Carried on the event rather than summed by the reader, because it is not a sum: a
* conversation's context drops at a compaction and a clear, so adding turns up would report a
* figure the session stopped being true of. Null where the dialect did not say, and on entries
* recorded before the backend sent it -- which leaves the context unmeasured rather than
* unchanged.
* [context] is prompt plus both cache figures. Carried on the event rather than summed by the
* reader, because it is not a sum: a conversation's context drops at a compaction and a clear,
* so adding turns up would report a figure the session stopped being true of. Null where the
* dialect did not say, which leaves the context unmeasured rather than unchanged.
*/
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
data class UsageDelta(
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.
*
* The counts are nullable because the server sends them only when it was told them: a
* compaction whose size nobody measured has to be able to say so, since a zero here would read
* as "recovered nothing" and a made-up number would read as a measurement.
* The counts are nullable because the server sends them only when it was told them: a zero here
* would read as "recovered nothing" and a made-up number would read as a measurement.
*/
data class Compacted(
val preTokens: Long?,
@@ -163,27 +221,37 @@ sealed class SessionEvent {
/**
* The conversation was cleared. Everything above this is still here to read and is no longer in
* the session's context.
*
* An object rather than a class because it carries nothing: what it means is entirely its
* the session's context. An object rather than a class because what it means is entirely its
* position in the transcript.
*/
data object Cleared : SessionEvent()
/**
* The session stopped because its account's usage limit was reached.
*
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
* limit reached|1788546972`, which is not readable by the person it is shown to.
*
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
*/
data class LimitReached(val resetsAt: Double?) : SessionEvent()
data class AuthenticationRequired(val message: String) : SessionEvent()
data class Error(val message: String) : SessionEvent()
/**
* An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new
* event kind degrades to a placeholder row instead of killing the stream.
* An event type this app build doesn't know -- a newer server. Kept rather than thrown so one
* new event kind degrades to a placeholder row instead of killing the stream.
*/
data class Unknown(val type: String) : SessionEvent()
}
/**
* A JSON array of strings under [name], empty when the field is absent.
*
* Absent is the ordinary case -- most messages carry no attachment, and the server omits the field
* rather than sending an empty list -- so this is the shape every caller wants.
* A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since
* the server omits the field rather than sending an empty list.
*/
private fun JSONObject.stringList(name: String): List<String> {
val array = optJSONArray(name) ?: return emptyList()
@@ -208,12 +276,15 @@ fun parseSeqEvent(json: String): SeqEvent {
)
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
"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" ->
SessionEvent.ToolStart(
id = body.getString("id"),
tool = body.getString("tool"),
// Kept as raw JSON text: the input shape is the tool's own
// business, and the UI only ever shows it verbatim.
// Kept as raw JSON text: the input shape is the tool's own business, and the UI
// only ever shows it verbatim.
input = body.get("input").toString(),
)
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
@@ -255,19 +326,26 @@ fun parseSeqEvent(json: String): SeqEvent {
body.getString("text"),
if (body.has("turnStart")) body.getLong("turnStart") else null,
)
"unreadable" -> SessionEvent.Unreadable(body.getString("kind"))
"taskNote" -> SessionEvent.RetiredTaskNote
"commandQueued" ->
SessionEvent.CommandQueued(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"))
"settings" ->
SessionEvent.Settings(
model = body.optString("model").ifEmpty { null },
permissionMode = body.optString("permissionMode").ifEmpty { null },
)
"images" -> SessionEvent.Images(imageSupport(body.optString("images")))
"contextWindow" -> SessionEvent.ContextWindow(body.getLong("tokens"))
"usageDelta" ->
SessionEvent.UsageDelta(
body.getLong("tokens"),
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" ->
SessionEvent.Compacted(
@@ -276,44 +354,76 @@ fun parseSeqEvent(json: String): SeqEvent {
trigger = body.optString("trigger").ifEmpty { null },
)
"cleared" -> SessionEvent.Cleared
"limitReached" ->
SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
)
"authenticationRequired" ->
SessionEvent.AuthenticationRequired(body.getString("message"))
"error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type)
}
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
}
/**
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
*
* One predicate because two readers have to agree on the list: the session screen's working
* indicator, and the fold's decision that the newest reply is finished. Two copies would drift the
* first time the server grows a state, and the drift would be a reply that never splits or one
* split mid-stream.
*/
fun sessionWorking(state: String): Boolean =
state == "running" || state == "compacting" || 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 same rule the server folds with, because the screen has to keep up between page loads: the
* summary it opened with is a measurement from before this stream started, and every event that
* moves the figure arrives here.
* summary it opened with is a measurement from before this stream started.
*
* The two that lower it are the point. A clear takes the conversation away and a compaction
* replaces it with a summary, so a figure measured before either stopped being true at that moment
* -- and carrying it forward is how a session that had just been cleared went on reporting the
* context it no longer had.
*
* Null is "we don't know", which is a state each of them can reach: nothing measured yet, a
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
* since.
* Null is "we don't know", which each of them can reach.
*/
/**
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
*
* One predicate because two readers have to agree on the list: the session screen's working
* indicator, and the fold's decision that the newest reply is finished
* ([TranscriptItem.AssistantMsg.settled]). Two copies would drift the first time the server grows a
* state, and the drift would be a reply that never splits or one split mid-stream.
*/
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
fun contextAfter(current: Long?, event: SessionEvent): Long? =
when (event) {
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a
// turn -- which every context figure is -- rather than unknown.
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn
// -- which every context figure is -- rather than unknown.
is SessionEvent.UsageDelta -> event.context ?: current
is SessionEvent.Compacted -> event.postTokens
is SessionEvent.Cleared -> null
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
}
@@ -0,0 +1,127 @@
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, drawn inside the empty box. It had a grey line of its own
* above the box until 2026-09-21: a form of a dozen settings was then mostly explanation, and a
* setting should be a title and a box to type in. Inside, it costs no height and is gone the moment
* anybody types -- which is the trade, since that is also when somebody might look back at it.
*/
@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))
FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions, hint)
}
}
/** 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,
hint: String?,
) {
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 {
// Under the text rather than beside it: the value is what the box is for, and a
// hint that pushed it sideways would move every character as somebody typed.
if (value.isEmpty() && hint != null) {
Text(hint, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
field()
}
},
)
}
@@ -26,7 +26,7 @@ import androidx.compose.ui.text.style.TextAlign
/**
* The largest file this app will open in the editor, in bytes.
*
* Measured on the emulator on 2026-09-04, in a debug build, on generated Rust:
* Measured on the emulator 2026-09-04, in a debug build, on generated Rust:
*
* | file | lines | scan per keystroke | worst frame record | typing |
* |--------|--------|--------------------|--------------------|-------------------|
@@ -35,15 +35,13 @@ import androidx.compose.ui.text.style.TextAlign
* | 1 MB | 28,660 | -- | -- | stops responding |
*
* The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file
* costs 40ms a keystroke, which is noticeable and survivable, while laying the same text out in one
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- which is what
* EXPLORER.md expected to have to decide -- would not have saved it; the cost is Compose laying out
* one enormous text, and every arrangement of a single text field pays it. A line-by-line editor is
* the way past this and is a good deal more than this feature needed.
* costs 40ms a keystroke, which is survivable, while laying the same text out in one
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- what
* EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single
* text field pays that cost. A line-by-line editor is the way past this.
*
* 32 kB rather than something between it and 128 kB, because 32 kB is the largest size that was
* actually measured as usable. The viewer's own limit stays the server's `FILE_LIMIT` of 1 MiB:
* reading a big file is fine, and it is only editing one that is not.
* 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays
* the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not.
*/
const val EDIT_LIMIT = 32L * 1024
@@ -53,18 +51,15 @@ const val EDIT_LIMIT = 32L * 1024
* `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement
* that colours a field's own text rather than replacing the field with something that only looks
* like one: the transformation returns the text unchanged and the scanner's spans as styles, so
* [OffsetMapping.Identity] is correct by construction -- no character moves, so no offset does. The
* newer `TextFieldState` API has no hook for styles at all, which is why this is the older one.
* [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook
* for styles at all.
*
* The cost is that the whole file is re-scanned on every keystroke. For a file under the server's
* limit that is expected to be a few milliseconds; see EXPLORER.md's "Numbers to measure", which is
* where a size below which highlighting is switched off would be decided if it turns out to be
* needed.
* The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is
* sized against.
*
* The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because
* there are no rows here -- the field is one text object. It stays put while the text scrolls
* sideways, and it lines up for the same reason the viewer's does: nothing wraps, so a logical line
* is a visual line.
* there are no rows here -- the field is one text object. It lines up for the same reason the
* viewer's does: nothing wraps, so a logical line is a visual line.
*/
@Composable
fun FileEditor(
@@ -12,10 +12,8 @@ import androidx.compose.ui.text.buildAnnotatedString
* and again on every recomposition.
*
* Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text
* layout is linear in the text and a twenty-thousand-line file in one `Text` measures all of it to
* draw a screenful. That means each row needs *its* colours, and the scanner answers in offsets
* into the whole file -- so the spans are bucketed here, once, in one pass over an already-ordered
* list, rather than each row searching the whole list for the part that is its.
* layout is linear in the text. That means each row needs *its* colours, and the scanner answers in
* offsets into the whole file -- so the spans are bucketed here, once, in one pass.
*/
class FileLines
private constructor(
@@ -37,11 +35,9 @@ private constructor(
get() = lines.size
/**
* One line, coloured.
*
* Built when the row is composed rather than up front: a file has far more lines than a screen
* shows, and an `AnnotatedString` per line for all of them is the cost the lazy list exists to
* avoid.
* One line, coloured. Built when the row is composed rather than up front: a file has far more
* lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the
* lazy list exists to avoid.
*/
fun line(index: Int): AnnotatedString {
val text = lines[index]
@@ -60,16 +56,13 @@ private constructor(
*
* Exactly one trailing newline is dropped before splitting, so a file that ends the way
* text files are supposed to end has the number of lines its author would count -- `wc -l`
* agrees, and so does every editor. Without that, every well-formed file gained a phantom
* empty last line, which is a wrong line number on every file in the repository. An empty
* file is one empty line numbered 1, which is what it is: a file with nothing in it still
* has somewhere for a cursor to go.
* agrees. Without that, every well-formed file gained a phantom empty last line. An empty
* file is one empty line numbered 1, which is what it is.
*/
fun of(text: String, language: Language?): FileLines =
// Timed, and always, for the same reason everything else here is: the cost of opening
// a large file is the number that decides whether the server's size limit is right,
// and an instrument that is only in the build nobody is running answers nothing. It
// lands in the render report beside the transcript's own figures.
// Timed, and always, for the reason everything else here is: the cost of opening a
// large file is the number that decides whether the server's size limit is right, and
// an instrument that is only in the build nobody is running answers nothing.
DebugStats.timed("file scanned and cut into lines") {
val body = text.removeSuffix("\n")
val lines = body.split('\n')
@@ -80,10 +73,9 @@ private constructor(
/**
* How many columns a line occupies.
*
* A tab counts as eight rather than as one, and deliberately upwards: this decides how far
* the viewer can scroll, and over-estimating leaves a little empty space past the longest
* line where under-estimating makes the end of that line unreachable. Compose draws a tab
* as a single advance, so eight is the generous reading rather than the accurate one.
* A tab counts as eight rather than one, and deliberately upwards: this decides how far the
* viewer can scroll, and over-estimating leaves a little empty space past the longest line
* where under-estimating makes the end of that line unreachable.
*/
private fun columnsOf(line: String): Int {
var count = 0
@@ -95,10 +87,9 @@ private constructor(
* The scanner's spans, in file offsets, as spans per line in line offsets.
*
* One walk down both lists, which is what the scanner's guarantee buys: its spans come out
* ordered, non-overlapping and inside the text, so a span can only belong to the line the
* walk has reached or to ones after it. A span crossing a line break -- a block comment, a
* multi-line string -- is cut at each break and appears in each line it covers, because a
* row is drawn on its own and cannot inherit a colour from the row above.
* ordered, non-overlapping and inside the text. A span crossing a line break is cut at each
* break and appears in each line it covers, because a row is drawn on its own and cannot
* inherit a colour from the row above.
*/
private fun bucket(lines: List<String>, spans: List<Span>): List<List<Span>> {
val out = ArrayList<List<Span>>(lines.size)
@@ -50,15 +50,12 @@ fun codeStyle(): TextStyle =
/**
* [content] scanned off the main thread, then drawn.
*
* Measured on the emulator on 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file
* (28,660 lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was
* first written, that is 460ms of frozen screen at the size the server is willing to send -- long
* enough that the accessibility tree cannot be read, which is what "the app has stopped" looks like
* from outside. So it runs on [Dispatchers.Default] and the spinner is what the reader sees
* meanwhile, in the place the file will appear.
* Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660
* lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first
* written, that is 460ms of frozen screen at the size the server is willing to send -- long enough
* that the accessibility tree cannot be read, which is what "the app has stopped" looks like.
*
* Keyed on the text and the language, so re-reading the same file does not rescan it and a file
* that changed does.
* Keyed on the text and the language, so re-reading the same file does not rescan it.
*/
@Composable
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
@@ -76,39 +73,31 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
*
* A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful, and the
* scroll never recovers. The cost of the choice is that each row needs its own colours, which is
* what [FileLines] works out once and off this thread.
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is
* that each row needs its own colours, which is what [FileLines] works out once and off this
* thread.
*
* Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a
* block and a long line does not silently become three -- which would put the gutter's numbers
* against the wrong text, the one thing a numbered listing must never do. Because nothing wraps, a
* logical line is one visual line and the two cannot drift.
* against the wrong text.
*
* **Every row is given the same content width**, and that is what makes the shared scroll state
* behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset
* into *its own* range -- `content width - viewport` -- so with rows of their natural widths a
* short line's range is zero and it never moves at all while a long one beside it does. Each row
* also writes `maxValue` on the shared state as it measures, so how far the file could be dragged
* was decided by whichever row happened to measure last and changed as the list scrolled. Both
* disappear once every row is [FileLines.columns] wide: one range, one maximum, and the file moves
* as the block this comment always claimed it was. Reported by Iris on 2026-09-04 as "it seems to
* affect different rows differently", which is exactly what a per-row range looks like.
* short line's range is zero and it never moves while a long one beside it does. Each row also
* writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever
* row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on
* 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range
* looks like.
*
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
* around the list rather than by each row. `horizontalScroll` makes its own per node otherwise, so
* only the line under the finger stretched and the rest of the file sat still beside it -- the same
* complaint as the offsets above, one layer further out. Handing every row the same effect and
* rendering it once is what makes the file bend as the block it scrolls as. Only possible because
* every row now has the same range: rows that disagreed about where the end was would disagree
* about when to stretch.
* around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise,
* so only the line under the finger stretched. Only possible because every row now has the same
* range.
*
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
* numbers out of both effects: they do not travel with the text and they do not bend with it. The
* rows leave a spacer where the numbers will go and [LineGutter] draws them there. Its width is
* measured from the digit count of the line count in the very style it is drawn in, so a nine-line
* file and a twelve-thousand-line file each get exactly what they need and nothing is nudged by
* hand.
* numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width
* is measured from the digit count of the line count in the style it is drawn in.
*
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
* copying it gives the code rather than the code with a number in front of every line.
@@ -139,8 +128,8 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
softWrap = false,
// The scroll outside the width: the scrolling node's viewport is
// what the row has room for, and its content is the whole file's
// widest line. The shared effect is given to every row and
// rendered by none of them -- see the box above.
// widest line. The shared effect is given to every row and rendered
// by none of them -- see the box above.
modifier =
Modifier.horizontalScroll(scroll, overscroll).width(content),
)
@@ -157,24 +146,20 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
* The line numbers, drawn beside the file rather than in it.
*
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they
* have to stay exactly level with the lines they number, which is the one thing a numbered listing
* may never get wrong. Those two pull in opposite directions -- out of the list, but pinned to it.
* have to stay exactly level with the lines they number. Those two pull in opposite directions.
*
* A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come
* from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during
* measurement, so this is not composing from a value it read a frame ago, it is composing from the
* answer the list has just produced. A `Column` translated by the scroll position could not do
* that: the translation would be a layout read and current while the set of numbers would be a
* composition behind it, so during a fling the numbers would slide against their lines.
* measurement, so this composes from the answer the list has just produced rather than one it read
* a frame ago. A `Column` translated by the scroll position could not: the translation would be
* current while the set of numbers was a composition behind, so during a fling the numbers would
* slide against their lines.
*
* The list is measured before this is -- they are siblings in a `Box` and it is declared first --
* and a scroll that remeasures the list on its own does so synchronously, ahead of the layout pass,
* which is the same reason a lazy list does not lag its own content.
* The list is measured before this is -- they are siblings in a `Box` and it is declared first.
*
* `onSurfaceVariant`, because a number is not part of the file: it is this app numbering it, and
* the text's own colour would put it in the same voice as the code. The background is painted
* because the stretch can carry the text sideways under this column, and a digit with a smear of
* code behind it reads as a rendering fault.
* `onSurfaceVariant`, because a number is not part of the file. The background is painted because
* the stretch can carry the text sideways under this column, and a digit with a smear of code
* behind it reads as a rendering fault.
*/
@Composable
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
@@ -205,10 +190,9 @@ private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
/**
* How wide the widest line number is, measured rather than guessed.
*
* `9` repeated, because digits in a monospace face are all one width and the count's own digits
* would measure the same -- what matters is how many there are. Measuring in the style the numbers
* are drawn in is what makes this survive a font size, a density or a display scale nobody here
* chose.
* `9` repeated, because digits in a monospace face are all one width -- what matters is how many
* there are. Measuring in the style the numbers are drawn in is what makes this survive a font
* size, a density or a display scale nobody here chose.
*/
@Composable
fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
@@ -225,15 +209,15 @@ fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
/**
* How wide to make every row: the widest line in the file, in this style.
*
* One character measured rather than the line itself, because the face is monospace -- every
* advance is the same -- and measuring the actual widest line of a twenty-thousand-line file is
* work for an answer arithmetic already has. Sixty-four of them, divided, so the answer does not
* carry a whole character's worth of rounding.
* One character measured rather than the line itself, because the face is monospace and measuring
* the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already
* has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of
* rounding.
*
* Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary
* one: a minified file is a single line of a hundred thousand characters, and asking to lay that
* out as one row is a crash rather than a slow scroll. Past the cap the far end of such a line
* cannot be reached, which is the tolerable half of that trade.
* one: a minified file is a single line of a hundred thousand characters, and laying that out as
* one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be
* reached, which is the tolerable half of that trade.
*/
@Composable
private fun contentWidth(columns: Int, style: TextStyle): Dp {
@@ -252,9 +236,7 @@ private fun contentWidth(columns: Int, style: TextStyle): Dp {
private const val MAX_CONTENT_PX = 100_000f
/**
* The space between the numbers and the code.
*
* A gap, not an alignment: the two are already aligned by the row, and this is only so the digits
* and the first character of the line are not touching.
* The space between the numbers and the code. A gap, not an alignment: the two are already aligned
* by the row, and this is only so the digits and the first character are not touching.
*/
val GUTTER_GAP = 8.dp
@@ -20,7 +20,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -44,100 +43,165 @@ import kotlinx.coroutines.withContext
/**
* Which machine's files to show, and where to start.
*
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the setups tab, say -- one more
* A **machine**, 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
* caller rather than any new code here.
*/
data class FilesTarget(val setup: String, val setupName: String, val start: String)
data class FilesTarget(
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. */
private sealed class Spot(val path: String) {
class Dir(path: String) : Spot(path)
class Doc(path: String) : Spot(path)
class Doc(path: String, val directory: Dir) : Spot(path)
}
private enum class UnsavedDestination {
Directory,
Session,
}
/**
* The files on the machine a session runs on: browse them, read one, change one.
*
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing, its draft and scroll position stay where they were, and coming back from a file costs
* nothing. Back steps one level inside here -- editor to viewer, viewer to the directory it came
* from, directory to the one above it -- and only closes from where it opened.
* flowing and coming back from a file costs nothing. Both back controls return from a file to its
* directory. In a directory, Android back walks toward the session's project directory and closes
* the explorer once it gets there; the header's back button closes it immediately.
*
* Every directory that has been visited is kept for as long as this is open, so stepping back is
* instant; the refresh glyph is how a directory gets asked again on purpose, and creating something
* refetches the directory it was created in, since that is the one thing that changed.
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* how one gets asked again on purpose, and creating something refetches the directory it was
* created in.
*/
@Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope()
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
val initialDirectory =
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>>() }
var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below,
// because they are what back has to know about -- and back arrives from two places, the arrow
// and the platform's own gesture, which must mean the same thing.
// because both ways out have to ask before discarding it.
var editing by remember { mutableStateOf(false) }
var dirty by remember { mutableStateOf(false) }
var askUnsaved by remember { mutableStateOf(false) }
val here = stack.last()
var unsavedDestination by remember { mutableStateOf<UnsavedDestination?>(null) }
fun go(spot: Spot) {
editing = false
dirty = false
stack = stack + spot
here = spot
}
fun back() {
when {
editing && dirty -> askUnsaved = true
editing -> editing = false
stack.size > 1 -> {
stack = stack.dropLast(1)
editing = false
dirty = false
}
else -> onClose()
fun leave(destination: UnsavedDestination) {
if (editing && dirty) {
unsavedDestination = destination
} else if (destination == UnsavedDestination.Directory) {
go((here as Spot.Doc).directory)
} else {
onClose()
}
}
suspend fun load(path: String, again: Boolean) {
if (!again && listings[path] is LoadState.Loaded) return
val existing = listings[path]
if (!again && (existing is LoadState.Loaded || existing is LoadState.Loading)) return
listings[path] = LoadState.Loading
listings[path] =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.setup, path))
LoadState.Loaded(fetchDir(settings, target.machine, path))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
BackHandler(onBack = ::back)
val projectDirectory = (listings[target.start] as? LoadState.Loaded)?.value?.path
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(
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
// The session under this deliberately takes no keyboard inset (see SessionScreen's
// layout note), so the explorer adds its own -- otherwise the editor types under the
// keyboard.
// The session under this deliberately takes no keyboard inset, so the explorer adds its
// own -- otherwise the editor types under the keyboard.
.imePadding()
) {
Column(Modifier.fillMaxSize()) {
when (val spot = here) {
is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading
// The resolved path once there is one: a directory opened as `~` is called
// what it turned out to be, not what it was asked for.
// Navigate with the resolved path, but name anything under the machine's home
// the way somebody working there would write it.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
val shownAt = tildePath(at, homeDirectory)
FilesHeader(
title = baseName(at),
path = at,
machine = target.setupName,
onBack = ::back,
title = baseName(shownAt),
path = shownAt,
machine = target.machineName,
onBack = { leave(UnsavedDestination.Session) },
) {
GlyphButton(
REFRESH_GLYPH,
@@ -153,7 +217,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
)
}
LaunchedEffect(spot.path) { load(spot.path, again = false) }
DirectoryBody(state, onOpen = ::go)
DirectoryBody(state, directory = spot, onOpen = ::go)
}
is Spot.Doc ->
DocPane(
@@ -162,22 +226,26 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
path = spot.path,
name = baseName(spot.path),
editing = editing,
homeDirectory = homeDirectory,
onEditing = { editing = it },
onDirty = { dirty = it },
onBack = ::back,
onBack = { leave(UnsavedDestination.Directory) },
)
}
}
}
if (askUnsaved) {
unsavedDestination?.let { destination ->
UnsavedDialog(
onDiscard = {
askUnsaved = false
editing = false
dirty = false
unsavedDestination = null
if (destination == UnsavedDestination.Directory) {
go((here as Spot.Doc).directory)
} else {
onClose()
}
},
onCancel = { askUnsaved = false },
onCancel = { unsavedDestination = null },
)
}
@@ -186,7 +254,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
if (creating && dir != null && listing != null) {
CreateDialog(
settings = settings,
setup = target.setup,
machine = target.machine,
directory = listing.path,
onDismiss = { creating = false },
onCreated = { path, isDirectory ->
@@ -197,7 +265,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
load(dir.path, again = true)
// A new file has nothing to look at, so it opens where it can be filled in.
if (!isDirectory) {
go(Spot.Doc(path))
go(Spot.Doc(path, dir))
editing = true
}
}
@@ -249,7 +317,11 @@ private fun FilesHeader(
* looks like a right one.
*/
@Composable
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
private fun ColumnScope.DirectoryBody(
state: LoadState<Listing>,
directory: Spot.Dir,
onOpen: (Spot) -> Unit,
) {
when (state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
is LoadState.Error ->
@@ -290,7 +362,9 @@ private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot)
name = entry.name,
trailing = trailingOf(entry),
onClick = {
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
onOpen(
if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path, directory)
)
},
)
}
@@ -304,9 +378,8 @@ private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot)
*
* A symlink says so instead of giving a size, because the size a listing reports for one is the
* length of the path it points at -- a number that looks exactly like a file size and is about
* something else entirely. `other` covers a fifo, a device, and a link whose target is gone: the
* row still appears, because a directory that hid what it held would be lying about being empty,
* and the word is there because a colour cannot say "this is a different kind of thing".
* something else. `other` covers a fifo, a device, and a link whose target is gone: the row still
* appears, because a directory that hid what it held would be lying about being empty.
*/
private fun trailingOf(entry: DirEntry): String? =
when {
@@ -350,8 +423,7 @@ private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: ()
*
* Its own composable so that everything about one file -- what came back, what has been typed, and
* whether a save is out -- is remembered under that file's path and thrown away when the reader
* moves to another. What is *not* here is edit mode itself: back has to know about it, and back
* belongs to the screen.
* moves to another. What is *not* here is edit mode itself: back has to know about it.
*/
@Composable
private fun ColumnScope.DocPane(
@@ -360,6 +432,7 @@ private fun ColumnScope.DocPane(
path: String,
name: String,
editing: Boolean,
homeDirectory: String?,
onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit,
onBack: () -> Unit,
@@ -384,7 +457,7 @@ private fun ColumnScope.DocPane(
state = LoadState.Loading
state =
try {
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.machine, path) }
if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got)
} catch (e: ApiException) {
@@ -407,7 +480,7 @@ private fun ColumnScope.DocPane(
try {
val written =
withContext(Dispatchers.IO) {
writeFile(settings, target.setup, path, draft.text, against)
writeFile(settings, target.machine, path, draft.text, against)
}
state =
LoadState.Loaded(
@@ -423,8 +496,8 @@ private fun ColumnScope.DocPane(
onDirty(false)
onEditing(false)
} catch (e: ApiException) {
// The one refusal that is a question rather than a message: somebody else's edit
// is on the machine, and which of the two survives is not this app's to decide.
// The one refusal that is a question rather than a message: somebody else's edit is
// on the machine, and which of the two survives is not this app's to decide.
if (e.status == 409) conflict = e.message ?: "It changed on the machine."
else saveError = e.message
} finally {
@@ -433,7 +506,12 @@ private fun ColumnScope.DocPane(
}
}
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
FilesHeader(
title = name,
path = tildePath(path, homeDirectory),
machine = target.machineName,
onBack = onBack,
) {
if (editing) {
if (saving) {
GlyphSpinner("Saving")
@@ -467,10 +545,10 @@ private fun ColumnScope.DocPane(
)
}
// Why the pencil is off. A disabled control teaches what the thing can do, but it cannot say
// why it is disabled -- and a reader who cannot edit a file they can plainly read will
// otherwise conclude the app is broken. Said once, here, rather than waiting for a tap that a
// disabled button never receives.
// Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
// is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
// conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
// never gets.
if (loaded != null && !editable) {
Text(
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
@@ -530,7 +608,9 @@ private fun ColumnScope.DocPane(
scope.launch {
val fresh =
try {
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
withContext(Dispatchers.IO) {
fetchFile(settings, target.machine, path)
}
} catch (e: ApiException) {
saveError = e.message
conflict = null
@@ -574,7 +654,7 @@ private fun Note(text: String) {
@Composable
private fun CreateDialog(
settings: ServerSettings,
setup: String,
machine: String,
directory: String,
onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit,
@@ -594,8 +674,8 @@ private fun CreateDialog(
scope.launch {
try {
withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, setup, path)
else createFile(settings, setup, path)
if (isDirectory) createDir(settings, machine, path)
else createFile(settings, machine, path)
}
onCreated(path, isDirectory)
} catch (e: ApiException) {
@@ -612,13 +692,11 @@ private fun CreateDialog(
title = { Text("Create in ${baseName(directory)}") },
text = {
Column {
OutlinedTextField(
LabelledField(
label = "Name",
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -681,12 +759,40 @@ 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. */
internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/')
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
}
/** A resolved directory and a name in it, as one path. */
internal fun join(directory: String, name: String): String =
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
@@ -19,19 +19,15 @@ import androidx.compose.ui.platform.LocalContext
* The point of splitting it up is that "the scroll is laggy" has two completely different causes
* and one appearance. If the layout-and-measure and draw figures are small and the total is large,
* the time is going into rasterising and compositing, and no amount of doing less work per row will
* move it. If they are large, the work per row is the problem and it is ours to fix. Guessing
* between those two is how a day gets spent rewriting the half that was already fast.
* move it. If they are large, the work per row is the problem and it is ours to fix.
*
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
* broken down into the parts the UI thread is responsible for -- handling input, running
* animations, measuring and laying out, recording the draw -- and the parts after it.
* broken into the parts the UI thread is responsible for and the parts after it.
*
* One of these for the app, like [DebugStats], because the two are read as one report and
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
* and the counters were not, so a report copied after visiting two sessions divided every session's
* work by the newest one's frame count -- and printed the result as a per-frame measurement. It
* said 36.8 seconds of placement inside a 13.5 second window, and left "everything else" clamped at
* 0.00ms (0%), which reads as a screen whose whole cost is this app's own code.
* work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window.
*/
object FrameStats {
private val total = ArrayList<Long>()
@@ -54,7 +50,7 @@ object FrameStats {
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
// How long the frame waited for the UI thread to be free before it could start. Reported
// because the phases otherwise do not add up to the total, and the gap is the interesting
// part: it is the frame being held up by work that is not the frame's.
// part: the frame being held up by work that is not the frame's.
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
@@ -123,7 +119,7 @@ private const val CAP = 20_000
* Records into [FrameStats] for as long as this screen is on it.
*
* The listener is what comes and goes; what it writes into does not, so a report covers the same
* stretch of time as the counters beside it. See [FrameStats].
* stretch of time as the counters beside it.
*
* The listener is handed its own thread because the platform calls it for every frame and the
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
@@ -7,6 +7,8 @@ import androidx.compose.ui.text.buildAnnotatedString
/** What a span of code is, in the terms the palette has a colour for. */
enum class Kind {
ADDITION,
DELETION,
KEYWORD,
STRING,
LITERAL,
@@ -24,6 +26,8 @@ data class Span(val start: Int, val end: Int, val kind: Kind)
* one instance and lives with the rest of the palette.
*/
data class SyntaxPalette(
val addition: Color,
val deletion: Color,
val keyword: Color,
val string: Color,
val literal: Color,
@@ -34,6 +38,8 @@ data class SyntaxPalette(
) {
fun of(kind: Kind): Color =
when (kind) {
Kind.ADDITION -> addition
Kind.DELETION -> deletion
Kind.KEYWORD -> keyword
Kind.STRING -> string
Kind.LITERAL -> literal
@@ -44,15 +50,34 @@ 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.
*
* Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the same
* code is the same colours wherever it appears.
* Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever
* it appears.
*
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
* off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text
* which needs no colour of its own -- the style the caller draws it with carries that.
* off the drawing thread.
*
* The timing is the number the highlighter is judged by: the library this replaced took **174ms**
* on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted]
@@ -82,8 +107,7 @@ fun highlight(code: String, language: Language?): AnnotatedString {
* to the end of the code, which is also what it looks like while a fence is still being written.
*
* In ordinary code the order of recognition is comment, string, attribute, number, word, and
* finally a single punctuation or mark character. Punctuation and marks are coloured only in
* ordinary code, never inside a string or a comment.
* finally a single punctuation or mark character, which are coloured only in ordinary code.
*/
fun scan(code: String, rules: Rules): List<Span> = Scanner(code, rules).run()
@@ -156,8 +180,8 @@ private class Scanner(private val code: String, private val rules: Rules) {
at += comment.open.length
var depth = 1
while (at < code.length && depth > 0) {
// The closer is tried first so that a language whose two delimiters are the same
// string -- CoffeeScript's `###` -- closes rather than nesting forever.
// The closer is tried first so that a language whose two delimiters are the same string
// -- CoffeeScript's `###` -- closes rather than nesting forever.
if (starts(comment.close)) {
depth--
at += comment.close.length
@@ -49,11 +49,9 @@ private const val DELETING = "deleting"
/**
* What the rows further down a batch say while they wait their turn.
*
* Its own word rather than the operation's, because it is its own state and the difference is the
* kind that matters: nothing has been done to this session yet, so a batch stopped here leaves it
* exactly as it was. Marked from the moment the batch is handed over all the same -- a queued row
* that still looked ordinary was still tappable, and tapping it would import it a second time
* behind the batch already coming for it.
* Its own word rather than the operation's, because nothing has been done to this session yet, so a
* batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over
* all the same -- a queued row that still looked ordinary was still tappable.
*/
private const val WAITING = "waiting"
@@ -62,57 +60,50 @@ private const val WAITING = "waiting"
*
* A batch takes rows out of the list as each one lands, so everything below the one that went
* slides up -- and a tap already on its way then arrives at whichever row moved into that place. On
* this screen that means importing a session nobody chose, which is not something a second tap can
* undo.
* this screen that means importing a session nobody chose.
*
* Swallowed silently rather than shown, because anything drawn on every row a batch passes would be
* a flicker running down the list. Half a second: long enough to cover a tap already travelling
* when the row moved, short enough that it is not in the way of a deliberate one.
* a flicker running down the list.
*/
private const val SETTLE_MS = 500L
/**
* Continuing a Claude Code session the machine already has.
*
* The list is the machine's answer, not this app's: it asks a setup what sessions it holds and
* shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this
* screen into a file reader.
* The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so
* an enrolled phone cannot turn this screen into a file reader.
*
* Holding a row selects it and puts the screen in selection mode, where the options that act on a
* selection appear along the bottom. That exists because these arrive in bulk a machine
* accumulates dozens of abandoned sessions and one confirmation dialog per row is the reason
* selection appear along the bottom. That exists because these arrive in bulk -- a machine
* accumulates dozens of abandoned sessions -- and one confirmation dialog per row is the reason
* clearing them out was not worth doing.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
val scope = rememberCoroutineScope()
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Setup?>(null) }
var machines by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Machine?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// What is happening to each row right now, as the word the row shows: "importing" or
// "deleting". A map keyed by id rather than a flag per row, because the rows are rebuilt from
// whatever the server last said and this belongs to the request rather than to the session --
// the same arrangement the session list uses for its deletes.
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
// than a flag per row, because the rows are rebuilt from whatever the server last said and this
// belongs to the request rather than to the session.
var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which rows the reader has picked out. Empty means selection mode is off: there is no
// separate flag, because a selection mode with nothing selected is a state with no controls
// in it and no way to leave except Back.
// Which rows the reader has picked out. Empty means selection mode is off: a selection mode
// with nothing selected is a state with no controls in it and no way to leave except Back.
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
// Failures that belong to one row rather than to the screen, shown on that row. A batch is
// exactly where a single banner fails: nine deletes succeeded and one did not, and the
// banner cannot say which.
// exactly where a single banner fails: nine deletes succeeded and one did not, and the banner
// cannot say which.
var rowErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
// themselves, not a flag, so the dialog can say what it is about.
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
// Same default as the spawn screen, and for the same reason: a phone
// is the wrong place to answer "allow Bash?" forty times.
var permissionMode by remember { mutableStateOf("auto") }
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from
// it, so a tap reading it needs no recomposition and there is no timer to cancel when a
// second removal lands on top of the first.
// Set from the selected Claude provider rather than repeated in the app.
var permissionMode by remember { mutableStateOf("") }
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
// so a tap reading it needs no recomposition.
val movedAt = remember { mutableMapOf<String, Long>() }
fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS
@@ -120,12 +111,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Fetches the list and takes the row states from it.
*
* Taken from the answer rather than kept across the load: the server is what knows what is
* running, and this screen may be opening on work another screen -- or another phone --
* started. Anything held locally would be a second version of that, and the stale one.
* running, and this screen may be opening on work another phone started.
*/
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
suspend fun fetchInto(machine: Machine): LoadState<List<Importable>> =
try {
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, machine.id) }
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
LoadState.Loaded(rows)
@@ -133,10 +123,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
LoadState.Error(err.message ?: "Couldn't list sessions")
}
fun loadSessions(setup: Setup) {
fun loadSessions(machine: Machine) {
sessions = LoadState.Loading
selected = emptySet()
scope.launch { sessions = fetchInto(setup) }
scope.launch { sessions = fetchInto(machine) }
}
/** Takes a row out of the list, once the machine no longer has it to offer. */
@@ -150,9 +140,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
LaunchedEffect(reloadToken) {
setups =
machines =
try {
val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
val found = withContext(Dispatchers.IO) { fetchMachines(settings) }
found.firstOrNull()?.let {
chosen = it
loadSessions(it)
@@ -167,13 +157,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Hands [targets] to the server in one request, marking every row it covers.
*
* The request only *starts* the work -- the server runs it and says how each row went on the
* change stream, which is what lets this screen be left while a batch is still going. So there
* is nothing here to wait for and nothing to sequence: the rows are marked, the batch goes, and
* everything after that arrives as an event.
* change stream, which is what lets this screen be left while a batch is still going.
*
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it
* is importing" is a guess -- and the row is inert either way, which is the part that matters.
* is importing" is a guess.
*
* The selection is dropped as the work is handed over, not when it finishes: the screen goes
* back to how it started, and what says the work is happening is the rows it is happening to.
@@ -182,21 +170,18 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
selected = emptySet()
running = running + targets.associate { it.id to WAITING }
rowErrors = rowErrors - targets.map { it.id }.toSet()
val setup = chosen
val machine = chosen
val ids = targets.map { it.id }
scope.launch {
// One request for the whole batch, not one per row. Sent row by row, a handover was
// only as atomic as the network: the fourth of six could fail, or the screen could be
// left with two still unsent, and what came back was some rows running and some
// untouched -- indistinguishable, on the list, from rows nobody had picked. Now
// either the server has the batch or it has none of it, and this is the one place
// that can be true.
// only as atomic as the network, and what came back was some rows running and some
// untouched -- indistinguishable, on the list, from rows nobody had picked.
try {
withContext(Dispatchers.IO) { send(ids) }
} catch (err: Exception) {
// The server never took it, so nothing is running and no event will arrive to say
// so. This is the one failure the screen must report itself -- and it is now the
// whole batch's failure, which is the point: no row was singled out.
// so. This is the one failure the screen must report itself -- and it is the whole
// batch's failure, which is the point: no row was singled out.
running = running - ids.toSet()
rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
return@launch
@@ -205,41 +190,35 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Then ask what actually happened, if anything still looks outstanding.
//
// The change stream is a broadcast with no memory, so an operation that started and
// finished while it was still connecting is one nothing will ever be said about --
// and the row sits marked for ever. That is not hypothetical: with responses held
// back far enough for the stream to open late, one row of a pair of deletes cleared
// and the other stayed on "waiting".
// finished while it was still connecting is one nothing will ever be said about -- and
// the row sits marked for ever. That is not hypothetical: with responses held back far
// enough, one row of a pair of deletes cleared and the other stayed on "waiting".
//
// The listing is the repair, because it carries the same state the events do. Only
// when something still looks outstanding, so the ordinary case -- where the events
// arrived and the rows are already gone -- does not pay for a second listing, which
// is the most expensive call this screen makes.
if (setup != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are
// already saying what is happening to them is the flicker this screen avoids
// everywhere else.
sessions = fetchInto(setup)
// The listing is the repair, because it carries the same state the events do. Only when
// something still looks outstanding, so the ordinary case does not pay for a second
// listing, which is the most expensive call this screen makes.
if (machine != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are already
// saying what is happening to them is the flicker this screen avoids everywhere
// else.
sessions = fetchInto(machine)
}
}
}
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
LaunchedEffect(chosen?.id, provider?.name) {
permissionMode = provider?.defaultPermissionMode.orEmpty()
}
/**
* Imports [targets], and goes to the session it made when [thenOpen].
*
* One function for the tap and for the bar, differing in that one flag: continuing a session
* and then looking at it is what a tap on a row means, and a batch has several results and no
* reason to pick one of them to become the screen.
*/
/** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) {
val setup = chosen ?: return
val machine = chosen ?: return
val useProvider = provider ?: return
handOver(targets) { ids ->
startImport(
settings,
setup = setup.id,
machine = machine.id,
sessionIds = ids,
provider = useProvider.name,
permissionMode = permissionMode,
@@ -255,7 +234,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* it, which is the case where waiting is the right thing anyway.
*/
fun importAndOpen(target: Importable) {
val setup = chosen ?: return
val machine = chosen ?: return
val useProvider = provider ?: return
running = running + (target.id to IMPORTING)
rowErrors = rowErrors - target.id
@@ -265,7 +244,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
withContext(Dispatchers.IO) {
spawnSession(
settings,
setup = setup.id,
machine = machine.id,
provider = useProvider.name,
// Nothing to say: the server titles it from the session it continues.
title = "",
@@ -283,21 +262,20 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
}
// Live changes to what the server is doing to these sessions, for as long as this screen is
// up. The listing already carried the same state when the screen opened -- this is what keeps
// it current afterwards, including for work another screen or another phone started.
// Live changes to what the server is doing to these sessions, for as long as this screen is up.
// The listing already carried the same state when the screen opened -- this is what keeps it
// current afterwards, including for work another phone started.
//
// Failures here are deliberately quiet. There is nothing for a reader to do about a dropped
// event stream, and nothing is lost by one: every state it would have carried is in the next
// listing, which is what Refresh and re-entering the tab already fetch.
// event stream, and every state it would have carried is in the next listing.
val liveChanges = remember {
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
}
LaunchedEffect(chosen?.id) {
val setup = chosen?.id ?: return@LaunchedEffect
val machine = chosen?.id ?: return@LaunchedEffect
try {
while (true) {
val stream = ImportableStream(settings, setup)
val stream = ImportableStream(settings, machine)
liveChanges.set(stream)
try {
withContext(Dispatchers.IO) {
@@ -307,8 +285,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
running =
running + (change.session to (change.operation ?: WAITING))
// Gone from the machine either way: a delete removed the
// transcript, an import made it a session, and neither is
// something this list still has to offer.
// transcript, an import made it a session.
"finished" -> {
running = running - change.session
forget(change.session)
@@ -323,17 +300,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
// The screen leaving, not a failure -- and swallowing it would leave this
// loop reconnecting to a stream nobody is watching.
// The screen leaving, not a failure -- and swallowing it would leave this loop
// reconnecting to a stream nobody is watching.
throw e
} catch (_: Exception) {
// Retried below; the listing is the truth in the meantime.
//
// Any failure, not only an [ApiException]. A stream is an optimisation over
// the listing here, so nothing it can do is worth taking the app down for --
// and catching only the failure that was expected means an unexpected one
// reaches the top of the app and closes it, from a screen that is merely
// loading a list.
// Retried below; the listing is the truth in the meantime. Any failure, not
// only an [ApiException]: a stream is an optimisation over the listing here,
// and catching only the expected failure means an unexpected one closes the app
// from a screen that is merely loading a list.
} finally {
stream.close()
}
@@ -351,9 +325,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Nested inside MainScreen's own handler, so it wins while there is a selection.
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last
// row can still be scrolled to while it is up, and nothing is nudged by a number that was
// right for one font size.
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
// can still be scrolled to while it is up.
var barHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
@@ -370,24 +343,24 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
)
Spacer(Modifier.height(12.dp))
when (val loaded = setups) {
when (val loaded = machines) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> {
// Only worth choosing when there is a choice.
if (loaded.value.size > 1) {
Row(Modifier.fillMaxWidth()) {
loaded.value.forEach { setup ->
loaded.value.forEach { machine ->
TextButton(
onClick = {
chosen = setup
loadSessions(setup)
chosen = machine
loadSessions(machine)
}
) {
Text(
setup.name,
machine.name,
color =
if (setup.id == chosen?.id)
if (machine.id == chosen?.id)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -404,7 +377,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} else {
ChipGroup(
label = "Permissions",
options = PERMISSION_MODES,
options = provider?.permissionModes.orEmpty(),
selected = permissionMode,
onSelect = { permissionMode = it },
)
@@ -428,8 +401,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
}
// Beside nothing in particular, because a selection is not one row: the options that act
// on it belong to the screen, and the bottom is where a thumb already is.
// Beside nothing in particular, because a selection is not one row: the options that act on
// it belong to the screen, and the bottom is where a thumb already is.
if (selected.isNotEmpty()) {
val picked =
(sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
@@ -468,9 +441,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
confirmButton = {
TextButton(
onClick = {
val setup = chosen ?: return@TextButton
val machine = chosen ?: return@TextButton
confirming = null
handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
handOver(targets) { ids -> deleteImportable(settings, machine.id, ids) }
}
) {
// Coloured by consequence: this takes something away, wherever it appears.
@@ -486,8 +459,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* What can be done to the rows that are selected.
*
* Delete and Import only, for now: they are the two things this screen has ever done to a session,
* and an option that appears here has to work on every row in a selection rather than on the one
* somebody was thinking of.
* and an option that appears here has to work on every row in a selection.
*/
@Composable
private fun SelectionBar(
@@ -567,26 +539,23 @@ private fun ImportableList(
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.combinedClickable(
// Off while something is happening to this row --
// see [BusyItem], which draws that but deliberately
// leaves the gestures alone so the list still
// scrolls.
// Off while something is happening to this row -- see
// [BusyItem], which draws that but leaves the gestures
// alone so the list still scrolls.
enabled = running[session.id] == null,
onClick = {
if (settling(session.id)) return@combinedClickable
// In selection mode a tap is a selection, so the
// reader is never one mis-tap away from starting
// a CLI they were only picking rows for.
// reader is never one mis-tap away from starting a
// CLI they were only picking rows for.
//
// Outside it, a tap continues the session --
// except on a row that cannot be continued,
// where it selects instead. That row's only
// remaining action is Delete, and a tap that
// did nothing at all would be a worse answer
// than one that offers the thing it can do.
// Two `--resume` processes on one transcript
// each replay the other's writes, which is why
// this must not simply try.
// Outside it, a tap continues the session -- except
// on a row that cannot be continued, where it
// selects instead. That row's only remaining action
// is Delete, and a tap that did nothing at all
// would be a worse answer. Two `--resume` processes
// on one transcript each replay the other's writes,
// which is why this must not simply try.
if (selecting || session.inUse == "yes")
onToggle(session)
else onOpen(session)
@@ -606,8 +575,7 @@ private fun ImportableList(
Spacer(Modifier.width(8.dp))
// Beside the title, because "which one was I just in" is
// the question this list answers and the order already
// reflects it -- the reader should be able to see the
// ordering they are being given rather than infer it.
// reflects it.
Text(
relativeTime(session.modified),
style = MaterialTheme.typography.bodySmall,
@@ -616,12 +584,11 @@ private fun ImportableList(
}
Spacer(Modifier.height(4.dp))
// The path first, and the only thing here that is cut: it is
// one long value with no natural break, where the lines below
// it are short enough to wrap readably. Cut at the head,
// because a path is identified by its tail and these all
// share a long prefix. By the row's real width rather than a
// character count, which was one guess for every font size
// and screen.
// one long value with no natural break. Cut at the head,
// because a path is identified by its tail and these all share
// a long prefix. By the row's real width rather than a
// character count, which was one guess for every font size and
// screen.
session.cwd
.takeIf { it.isNotEmpty() }
?.let { cwd ->
@@ -648,8 +615,7 @@ private fun ImportableList(
color = warningColor,
)
}
// Reported where it happened, in the server's own words, the
// way every other failure in this app is shown.
// Reported where it happened, in the server's own words.
errors[session.id]?.let { message ->
Spacer(Modifier.height(4.dp))
Text(
@@ -673,14 +639,14 @@ private fun statsOf(session: Importable): String =
// Said, because a name and a last message are different claims: one describes the
// session, the other is only what happened last in it.
if (session.named) "named" else null,
// What continuing it costs, which is the question this list is really asked. First
// of the measurements for that reason, and absent rather than zero when nothing has
// been measured -- a session with no turns yet has no figure, not a figure of none.
// What continuing it costs, which is the question this list is really asked. Absent
// rather than zero when nothing has been measured -- a session with no turns yet has no
// figure, not a figure of none.
session.contextTokens?.let { "${it / 1000}k context" },
"${session.lines} lines",
// Kept beside the context figure because the two disagree usefully: most of a large
// transcript is history from before a compaction, which the model is no longer
// given, so a big file can be cheap to continue and a small one expensive.
// transcript is history from before a compaction, so a big file can be cheap to
// continue.
humanSize(session.bytes),
)
.joinToString(" · ")
@@ -689,16 +655,14 @@ private fun statsOf(session: Importable): String =
* Why this session might not be safe to take, if it isn't.
*
* Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind,
* and no shade distinguishes them. The colour is what makes it findable; the words are what make it
* actionable.
* and no shade distinguishes them.
*/
private fun warningOf(session: Importable): String? =
when (session.inUse) {
// What was measured is that a live process on that machine holds this session open. Which
// process is not measured, so it isn't claimed: "a terminal close it there first" sent
// people looking for a window that need not exist. It is just as likely another agent, or
// this app on a session it spawned. Naming a place the reader then can't find turns a
// correct refusal into a wrong instruction.
// process is not measured, so it isn't claimed: "a terminal -- close it there first" sent
// people looking for a window that need not exist. Naming a place the reader then can't
// find turns a correct refusal into a wrong instruction.
"yes" -> "something on that machine is running it"
"unknown" -> "can't tell if it's open"
else -> null
@@ -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 next listing.
*/
class ImportableStream(settings: ServerSettings, private val setup: String) {
class ImportableStream(settings: ServerSettings, private val machine: String) {
private val stream = Sse(settings)
fun close() = stream.close()
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
stream.run("/machines/$machine/importable/events", onOpen) { _, data ->
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
}
}
@@ -16,6 +16,7 @@ enum class Language {
CPP,
CSHARP,
DART,
DIFF,
FISH,
GO,
JAVA,
@@ -49,10 +50,9 @@ data class Rules(
/** Tokens that open a comment running to the end of the line. */
val lineComments: List<String> = emptyList(),
/**
* Whether [lineComments] count only at the start of a word.
*
* The shells need it: `$#`, `${#x}` and `a#b` are not comments, and greying the rest of those
* lines is one of the mistakes this scanner exists to stop.
* Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}`
* and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this
* scanner exists to stop.
*/
val lineCommentsAtWordStart: Boolean = false,
val blockComment: BlockComment? = null,
@@ -63,8 +63,8 @@ data class Rules(
val rawStrings: Boolean = false,
/**
* Rust: `'` opens a character literal only when a backslash or one character and a `'` follow.
* Otherwise it is a lifetime or a label and no string starts -- without this, `'a` opens a
* string that runs to the next apostrophe in the block.
* Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the
* next apostrophe in the block.
*/
val lifetimes: Boolean = false,
)
@@ -91,18 +91,17 @@ enum class Attributes {
* The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to
* be made of.
*
* Nearly every language here is tokens: keywords, strings and comments, which is a row of [RULES]
* and the one shared scanner in [scan]. Markdown has none of those, and what a character means
* there depends on where on the line it sits, so it brings a scanner of its own ([scanMarkdown]).
* That is the whole extension point -- a new language is a row of rules or an entry in [SCANNERS],
* and no caller learns which one it got.
* Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner.
* Markdown has none of those, and what a character means there depends on where on the line it
* sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a
* row of rules or an entry in [SCANNERS], and no caller learns which one it got.
*/
fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(language)(code)
// Lazy for the same reason [RULES] is, since it reads it.
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
mapOf(Language.MARKDOWN to ::scanMarkdown)
mapOf(Language.DIFF to ::scanDiff, Language.MARKDOWN to ::scanMarkdown)
}
private val C_STYLE = BlockComment("/*", "*/", nests = false)
@@ -140,8 +139,8 @@ private val RULES: Map<Language, Rules> by lazy {
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
),
// `###` opens and closes a block comment and `#` opens a line one, which is why the
// scanner tries the block opener first.
// `###` opens and closes a block comment and `#` opens a line one, which is why the scanner
// tries the block opener first.
Language.COFFEESCRIPT to
Rules(
keywords = KEYWORDS_COFFEESCRIPT,
@@ -162,8 +161,8 @@ private val RULES: Map<Language, Rules> by lazy {
keywords = KEYWORDS_FISH,
lineComments = listOf("#"),
lineCommentsAtWordStart = true,
// fish's single quotes escape only `\'` and `\\`, which is what "skip the
// character after a backslash" already does.
// fish's single quotes escape only `\'` and `\\`, which is what "skip the character
// after a backslash" already does.
quotes = listOf(DOUBLE, SINGLE),
),
Language.GO to
@@ -288,10 +287,9 @@ private val RULES: Map<Language, Rules> by lazy {
* The keyword sets.
*
* Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0
* (`SyntaxTokens.kt`, Apache-2.0), the library this scanner replaced, so that no fence which is
* coloured today turns plain. Entries that are not plain words were dropped -- Kotlin's `as?`,
* `!in` and `!is`, Swift's `#if` family, Ruby's `defined?`, CoffeeScript's `=` and `->` -- because
* the word scanner cannot reach them and the library only matched them by luck.
* (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns
* plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family,
* Ruby's `defined?` -- because the word scanner cannot reach them.
*/
private fun words(list: String): Set<String> =
list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet()
@@ -8,7 +8,7 @@ package com.example.aiapp
* empty list, which is the one wrong answer that looks like a right one.
*
* [Loading] and [Error] carry no payload, so they are `LoadState<Nothing>` and this is covariant in
* [T]: one `LoadState.Loading` serves every screen rather than each needing its own.
* [T]: one `LoadState.Loading` serves every screen.
*/
sealed class LoadState<out T> {
data object Loading : LoadState<Nothing>()
@@ -0,0 +1,402 @@
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,5 +1,7 @@
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.Row
import androidx.compose.foundation.layout.Spacer
@@ -10,9 +12,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -24,6 +26,11 @@ 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.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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -37,19 +44,25 @@ import kotlinx.coroutines.withContext
* which is what keeps the enrolled token from being able to introduce commands.
*/
@Composable
fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
fun MachinesScreen(
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()
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var state by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
var adding by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<Setup?>(null) }
var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
var renaming by remember { mutableStateOf<Machine?>(null) }
var confirmingDelete by remember { mutableStateOf<Machine?>(null) }
var signingIn by remember { mutableStateOf<Pair<Machine, Provider>?>(null) }
var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
@@ -58,8 +71,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
LaunchedEffect(reloadToken) { reload() }
Column(Modifier.fillMaxSize().padding(16.dp)) {
// The heading and Back are the tab row's now; adding a machine is this tab's own work
// and stays with the list it adds to.
// The heading and Back are the tab row's now; adding a machine is this tab's own work and
// stays with the list it adds to.
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { adding = true }) { Text("Add machine") }
}
@@ -82,19 +95,19 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
LazyColumn(Modifier.fillMaxSize()) {
uniqueItems(current.value, key = { it.id }) { setup ->
SetupCard(
setup = setup,
onRename = { renaming = setup },
uniqueItems(current.value, key = { it.id }) { machine ->
MachineCard(
machine = machine,
onRename = { renaming = machine },
onRediscover = {
scope.launch {
busy = "Asking ${setup.name} what it has…"
busy = "Asking ${machine.name} what it has…"
actionError =
runCatching {
withContext(Dispatchers.IO) {
updateSetup(
updateMachine(
settings,
setup.id,
machine.id,
rediscover = true,
)
}
@@ -105,7 +118,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
reload()
}
},
onDelete = { confirmingDelete = setup },
onDelete = { confirmingDelete = machine },
onSignIn = { provider -> signingIn = machine to provider },
onProvider = { provider -> onProvider(machine.id, provider.name) },
)
}
}
@@ -113,7 +128,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
}
if (adding) {
AddSetupDialog(
AddMachineDialog(
onDismiss = { adding = false },
onAdd = { name, ssh ->
adding = false
@@ -121,7 +136,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
busy = "Asking $name what it has…"
actionError =
runCatching {
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
withContext(Dispatchers.IO) { addMachine(settings, name, ssh) }
}
.exceptionOrNull()
?.message
@@ -129,13 +144,13 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
reload()
}
},
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } },
)
}
renaming?.let { setup ->
renaming?.let { machine ->
RenameDialog(
setup = setup,
machine = machine,
onDismiss = { renaming = null },
onRename = { name ->
renaming = null
@@ -143,7 +158,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
actionError =
runCatching {
withContext(Dispatchers.IO) {
updateSetup(settings, setup.id, name = name)
updateMachine(settings, machine.id, name = name)
}
}
.exceptionOrNull()
@@ -154,10 +169,10 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
)
}
confirmingDelete?.let { setup ->
confirmingDelete?.let { machine ->
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Remove \"${setup.name}\"?") },
title = { Text("Remove \"${machine.name}\"?") },
text = {
Text(
"The machine is left alone -- this only stops this app offering it. " +
@@ -171,7 +186,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
withContext(Dispatchers.IO) {
deleteMachine(settings, machine.id)
}
}
.exceptionOrNull()
?.message
@@ -187,35 +204,99 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
},
)
}
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
private fun SetupCard(
setup: Setup,
private fun MachineCard(
machine: Machine,
onRename: () -> Unit,
onRediscover: () -> Unit,
onDelete: () -> Unit,
onSignIn: (Provider) -> Unit,
onProvider: (Provider) -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(setup.name, style = MaterialTheme.typography.titleSmall)
Text(machine.name, style = MaterialTheme.typography.titleSmall)
Text(
// Not "this machine": the seeded setup is *called* that,
// and the card read "this machine / this machine". The
// line has to say something the name cannot also be.
setup.address ?: "runs where the backend does",
// Not "this machine": the seeded machine is *called* that, and the card read "this
// machine / this machine".
machine.address ?: "runs where the backend does",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
if (setup.providers.isEmpty()) {
"Nothing found on it. Install something and rediscover."
} else {
setup.providers.joinToString(" · ") { it.name }
},
style = MaterialTheme.typography.bodySmall,
)
if (machine.providers.isEmpty()) {
Text(
"Nothing found on it. Install something and rediscover.",
style = MaterialTheme.typography.bodySmall,
)
} else {
machine.providers.forEach { provider ->
// 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) {
TextButton(onClick = onRename) { Text("Rename") }
TextButton(onClick = onRediscover) { Text("Rediscover") }
@@ -227,7 +308,7 @@ private fun SetupCard(
}
@Composable
private fun AddSetupDialog(
private fun AddMachineDialog(
onDismiss: () -> Unit,
onAdd: (String, SshDetails?) -> Unit,
onTest: suspend (SshDetails?) -> List<Provider>,
@@ -237,6 +318,7 @@ private fun AddSetupDialog(
var address by remember { mutableStateOf("") }
var identity by remember { mutableStateOf("") }
var attachmentsDir by remember { mutableStateOf("") }
var modelsDir by remember { mutableStateOf("") }
var tested by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) }
@@ -251,6 +333,7 @@ private fun AddSetupDialog(
port = typedPort,
identityFile = identity.trim().ifEmpty { null },
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
modelsDir = modelsDir.trim().ifEmpty { null },
)
}
@@ -266,35 +349,36 @@ private fun AddSetupDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
)
OutlinedTextField(
LabelledField(label = "Name", value = name, onValueChange = { name = it })
Spacer(Modifier.height(8.dp))
LabelledField(
// 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 = "user@host[:port]",
value = address,
onValueChange = { address = it },
// Just the shape. What a blank one means is said once, in the text above
// this form -- repeating it here wrapped the label onto a second line and
// made this field taller than the two beside it for no information.
label = { Text("user@host[:port]") },
singleLine = true,
)
OutlinedTextField(
Spacer(Modifier.height(8.dp))
LabelledField(
label = "Key path on the backend",
value = identity,
onValueChange = { identity = it },
label = { Text("Key path on the backend") },
singleLine = true,
)
// Where a file attached from the phone lands on that machine. Blank means the
// session's own directory, which is what most people want and what needs no
// path typed on a phone.
OutlinedTextField(
Spacer(Modifier.height(8.dp))
LabelledField(
// Where a file attached from the phone lands on that machine.
label = "Folder for attached files",
value = attachmentsDir,
onValueChange = { attachmentsDir = it },
label = { Text("Folder for attached files (optional)") },
singleLine = true,
hint = "the session's own directory",
)
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 {
Spacer(Modifier.height(8.dp))
@@ -309,9 +393,8 @@ private fun AddSetupDialog(
},
dismissButton = {
Row {
// Tried before saving, so a wrong address or an
// unauthorised key is caught while this form is still on
// screen rather than at the first spawn.
// Tried before saving, so a wrong address or an unauthorised key is caught while
// this form is still on screen rather than at the first spawn.
TextButton(
enabled = !testing,
onClick = {
@@ -343,19 +426,14 @@ private fun AddSetupDialog(
}
@Composable
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(setup.name) }
private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(machine.name) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
)
LabelledField(label = "Name", value = name, onValueChange = { name = it })
Spacer(Modifier.height(8.dp))
Text(
"Sessions already running on it keep working -- they refer to the machine, " +
@@ -377,14 +455,13 @@ private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String)
/**
* Splits `user@host:port` into its two halves, with the port left null when none was typed.
*
* One field rather than two because that is how an address is written and read everywhere else --
* and because a port that is almost always 22 does not deserve a box of its own on a phone
* keyboard. Null rather than 22: the backend already decides the default, and writing 22 here would
* put a second answer to that question in a second place.
* One field rather than two because that is how an address is written and read everywhere else, and
* because a port that is almost always 22 does not deserve a box of its own on a phone keyboard.
* Null rather than 22: the backend already decides the default.
*
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
* `[::1]:22`; a bare `::1` keeps every colon, because an address with several is an address, not an
* address and a port. So the rule is: brackets, or exactly one colon followed by digits.
* `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon
* followed by digits.
*/
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
if (typed.startsWith("[")) {
@@ -28,14 +28,13 @@ import androidx.compose.ui.layout.layout
import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() {
// Bumped whenever enrollment lands via an aiapp:// intent so the
// composition below re-reads the stored settings.
// Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the
// stored settings.
private var settingsVersion by mutableIntStateOf(0)
// The session a notification tap asked for, or null if nothing has. The
// serial is what makes a second tap on the same session's notification a
// second request: without it the two compare equal and the composition
// below has nothing to react to.
// The session a notification tap asked for, or null if nothing has. The serial is what makes a
// second tap on the same session's notification a second request: without it the two compare
// equal and the composition below has nothing to react to.
private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
private var opens = 0
@@ -43,8 +42,8 @@ class MainActivity : ComponentActivity() {
private var shareRequest by mutableStateOf<ShareRequest?>(null)
private var shares = 0
// Registered up front since permission launchers must be registered
// before the activity reaches STARTED.
// Registered up front since permission launchers must be registered before the activity reaches
// STARTED.
private val requestLocalNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
@@ -52,8 +51,8 @@ class MainActivity : ComponentActivity() {
* The service starts either way, and posts nothing if this is refused.
*
* Deliberately not gated on the answer: the permission can be granted later from Android's own
* settings, and a service that only ever started at the moment it was granted would then stay
* down until the app was launched again -- which is the case notifications exist to avoid.
* settings, and a service that only ever started at the moment it was granted would stay down
* until the app was launched again.
*/
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
@@ -64,21 +63,17 @@ class MainActivity : ComponentActivity() {
// Before anything else that could throw, so the first crash of a launch is caught too.
installCrashLog(this)
// Transparent status bar on every version; the Surface below paints
// through underneath it and content insets itself. Same reasoning
// as dev-updater's MainActivity.
// Transparent status bar on every version; the Surface below paints through underneath it
// and content insets itself. Same reasoning as dev-updater's MainActivity.
enableEdgeToEdge()
// Dark status-bar icons only over a light background, decided from the scheme rather
// than fixed. It was hardcoded to `true` -- dark icons -- which was right against the
// default light surface and became unreadable the moment the app wore Catppuccin Mocha.
// Asking the colour means a future palette change cannot reintroduce that: whatever
// `background` becomes, the icons follow it.
// Dark status-bar icons only over a light background, decided from the scheme rather than
// fixed. It was hardcoded to `true`, which was right against the default light surface and
// became unreadable the moment the app wore Catppuccin Mocha.
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
AiAppColors.background.luminance() > 0.5f
// Android 17+ silently drops local-network traffic without this;
// requested up front because a denial is invisible at the socket
// layer (it just times out).
// Android 17+ silently drops local-network traffic without this; requested up front because
// a denial is invisible at the socket layer (it just times out).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
@@ -88,16 +83,14 @@ class MainActivity : ComponentActivity() {
}
handleIntent(intent)
// After enrollment, so a first launch that arrives with a token
// starts the service with something to connect to rather than
// stopping it and waiting for the next launch.
// After enrollment, so a first launch that arrives with a token starts the service with
// something to connect to rather than stopping it and waiting for the next launch.
NotificationService.sync(this)
setContent {
// Selection colours with the theme rather than at each place text is drawn: the
// transcript is one selection container, and a selection that ran from a reply into
// the code block under it would otherwise change colour halfway. See
// [AiAppSelectionColors].
// transcript is one selection container, and a selection that ran from a reply into the
// code block under it would otherwise change colour halfway.
MaterialTheme(colorScheme = AiAppColors) {
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
Surface(modifier = Modifier.fillMaxSize()) {
@@ -107,8 +100,7 @@ class MainActivity : ComponentActivity() {
// the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter -- every frame of the IME animation
// relays out and re-records this whole box.
// the case that made it matter.
Modifier.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
@@ -135,19 +127,16 @@ class MainActivity : ComponentActivity() {
}
.fillMaxSize()
.statusBarsPadding()
// The gesture strip at the bottom of most
// phones. Without it the send row sits under
// the swipe area, where a tap is as likely to
// navigate away as to press a button.
// The gesture strip at the bottom of most phones. Without it
// the send row sits under the swipe area, where a tap is as
// likely to navigate away as to press a button.
//
// No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every
// screen's entire tree per frame -- measured above as most of
// the frame budget. Each screen takes the keyboard itself
// (AppRoot wraps the ordinary ones; the session screen moves
// only its composer and transcript), so the per-frame cost is
// scoped to what actually moves.
// screen's entire tree per frame. Each screen takes the
// keyboard itself, so the per-frame cost is scoped to what
// actually moves.
.navigationBarsPadding()
) {
AppRoot(settingsVersion, openRequest, shareRequest)
@@ -158,9 +147,8 @@ class MainActivity : ComponentActivity() {
}
}
// launchMode="singleTop": an enrollment scan, or a notification tapped
// while the app is open, lands here rather than in a second activity
// instance.
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
// lands here rather than in a second activity instance.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
@@ -171,8 +159,7 @@ class MainActivity : ComponentActivity() {
*
* Three things arrive this way -- a share from another app, and an `aiapp://` URI that is
* either an enrollment code or a notification naming a session. The URIs are told apart by host
* rather than by two entry points, so a further kind is a branch here rather than another
* intent to remember to handle.
* rather than by two entry points, so a further kind is a branch here.
*/
private fun handleIntent(intent: Intent?) {
intent ?: return
@@ -195,8 +182,8 @@ class MainActivity : ComponentActivity() {
}
saveServerSettings(this, settings)
settingsVersion++
// Enrolling is the moment there is a backend to watch, and
// re-enrolling elsewhere is the moment the old one stops being it.
// Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the
// moment the old one stops being it.
NotificationService.sync(this)
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
}
@@ -0,0 +1,53 @@
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,21 +26,23 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
/**
* The app's root: one title, and four views of the backend behind it.
* The app's root: one title, and three views of the backend behind it.
*
* These were four screens reached by four words in a row under the title, and the row was already
* full -- the comment it replaced recorded that a fifth would have to go somewhere else. Tabs say
* the same thing in less space and say one more thing besides: that these are places to be rather
* than errands to run. Sessions, the machine's importable history, the models on it and the
* machines themselves are all *the same backend*, looked at four ways, and none of them is a step
* down from another. Settings still is a step down, which is why it stays a pushed screen and keeps
* its own Back.
* These were screens reached by words in a row under the title, and the row was already full. Tabs
* say the same thing in less space and say one more thing besides: that these are places to be
* rather than errands to run. Sessions, the machine's importable history and the machines
* themselves are all *the same backend*, looked at three ways, and none is a step 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) {
Sessions("Sessions"),
Import("Import"),
Models("Models"),
Setups("Setups"),
Machines("Machines"),
}
@Composable
@@ -53,6 +55,10 @@ fun MainScreen(
onSpawn: () -> Unit,
onImported: (SessionSummary) -> 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 refreshToken by remember { mutableIntStateOf(0) }
@@ -61,17 +67,12 @@ fun MainScreen(
//
// What these four draw is a snapshot of a backend they are not connected to, so it is only as
// fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A
// phone that was away while the tunnel was down, or that fetched before the network came up,
// came back to "Couldn't reach the server" sitting at the top of a list the server would now
// answer for perfectly well, and nothing took it off until somebody pressed Refresh. A stale
// failure is worse than a stale list: it is a claim about right now.
// phone that was away while the tunnel was down came back to "Couldn't reach the server"
// sitting at the top of a list the server would now answer for perfectly well. A stale failure
// is worse than a stale list: it is a claim about right now.
//
// Through the same token the Refresh button uses, so this is one instruction the tabs already
// understand rather than a second path into each of them -- which is also what makes it cover
// all four rather than the one the report came from.
//
// Not on the first entry: the tab composing already asks, and bumping here would make every
// cold start fetch twice.
// understand. Not on the first entry: the tab composing already asks.
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
var opening = true
@@ -81,9 +82,8 @@ fun MainScreen(
}
}
// A tab the app put over the list has to step back to it rather than fall through to the
// system default, which closes the app -- that reads as a crash to somebody who only meant to
// get back to their sessions. Nested inside AppRoot's handler, so it wins while it is enabled.
// A tab the app put over the list has to step back to it rather than fall through to the system
// default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled.
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
Column(Modifier.fillMaxSize()) {
@@ -98,20 +98,18 @@ fun MainScreen(
)
// Glyphs rather than the words they replaced: neither ever changes, both are read
// faster than they are spelled, and together they take the width that let the title
// keep its own line. They sit on the title's row because they act on the whole
// screen -- everything below this row is one tab's business, and a control belongs
// with the thing it acts on.
// Flush against each other: a glyph button carries its own padding, so two of them
// side by side already have two rings between their marks and one ring plus this
// row's padding to the screen edge.
// keep its own line. They sit on the title's row because they act on the whole screen.
//
// Flush against each other: a glyph button carries its own padding, so two side by side
// already have two rings between their marks.
Row {
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
}
}
// What is waiting to be attached, and what to do about it. Said here because the list
// below is where the choice is made, and a share that arrived with nothing on screen
// saying so would read as a tap that did nothing.
// What is waiting to be attached, and what to do about it. Said here because the list below
// is where the choice is made, and a share that arrived with nothing on screen saying so
// would read as a tap that did nothing.
share?.let {
Text(
it.summary() + " -- open the session it belongs in.",
@@ -127,8 +125,8 @@ fun MainScreen(
.padding(12.dp),
)
}
// Primary rather than the plain TabRow, which is deprecated in favour of the two that
// say where they sit: these are the app's top-level destinations.
// Primary rather than the plain TabRow, which is deprecated in favour of the two that say
// where they sit: these are the app's top-level destinations.
PrimaryTabRow(selectedTabIndex = tab.ordinal) {
MainTab.entries.forEach { entry ->
Tab(
@@ -139,10 +137,9 @@ fun MainScreen(
}
}
// Refreshing means "ask again about what I am looking at", so the button feeds the tab
// that is showing. The token from above means something else already changed what these
// show; the two are the same instruction to the tab below, so they are summed rather than
// tracked apart -- either one moving moves the sum, which is all a tab watches.
// Refreshing means "ask again about what I am looking at", so the button feeds the tab that
// is showing. The token from above means something else already changed what these show;
// the two are the same instruction, so they are summed rather than tracked apart.
val token = reloadToken + refreshToken
when (tab) {
MainTab.Sessions ->
@@ -151,11 +148,12 @@ fun MainScreen(
reloadToken = token,
onOpen = onOpen,
onSpawn = onSpawn,
onDeleted = onDeleted,
)
MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
MainTab.Machines ->
MachinesScreen(settings = settings, reloadToken = token, onProvider = onProvider)
}
}
}
@@ -69,13 +69,10 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
*
* [live] is the reply still arriving, and two things are different for it. Its parse is incremental
* -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole
* message. And its pieces get a layer each: when drawing is invalidated, only the piece that
* changed is re-recorded instead of the whole reply, which is worth a great deal while every delta
* invalidates the message and a finished one can be twenty-five screens tall. It is worth nothing
* once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65
* times in fifty seconds of reading -- and it is not free: each layer is a layout node and a
* display list held for the life of the row, and live node count is what the per-frame cost of the
* transcript scales with.
* message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is
* worth a great deal while every delta invalidates the message and worth nothing once it stops
* changing -- and it is not free: each layer is a layout node and a display list held for the life
* of the row, and live node count is what the transcript's per-frame cost scales with.
*/
@Composable
fun MarkdownText(
@@ -92,8 +89,8 @@ fun MarkdownText(
var previousSegment: Segment? = null
segments.forEachIndexed { at, segment ->
val nextContinues = segments.getOrNull(at + 1)?.continues == true
// Only the tail is still being written; a frozen segment is finished text that
// happens to sit in a live reply, and it takes its colours now. See [MarkdownRoot].
// Only the tail is still being written; a frozen segment is finished text that happens
// to sit in a live reply, and it takes its colours now.
MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) {
segment.pieces.forEachIndexed { index, piece ->
val gap =
@@ -103,9 +100,9 @@ fun MarkdownText(
if (segment.continues) 0.dp else BLOCK_SPACING
else -> gapBefore(previous, piece)
}
// Keyed by where the piece starts in the message rather than by its position
// in this column, so a delta landing in the last block leaves every other
// piece's composition alone -- and a block keeps its key when it freezes.
// Keyed by where the piece starts in the message rather than by its position in
// this column, so a delta landing in the last block leaves every other piece's
// composition alone -- and a block keeps its key when it freezes.
key(segment.start, piece) {
MarkdownPiece(
segment.parse,
@@ -137,8 +134,7 @@ fun MarkdownText(
* A stretch of a message with a parse of its own: the whole of a settled message, or one block, the
* finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins
* in the message. [continues] says the first piece is an item of the list the segment before it
* ended with, so the two draw as one list: no block gap between them, and neither the item above
* the seam nor the one below it takes the padding of a list's edge.
* ended with, so the two draw as one list.
*/
private class Segment(
val text: String,
@@ -154,14 +150,11 @@ private class Segment(
*
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
* height, and the transcript above it collapses and springs back. Seen with five replies on screen
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
* later it was all there again. That is the "skipping up and down" this list must never do.
* height, and the transcript above it collapses and springs back -- seen with five replies on
* screen at once, the whole conversation shrunk to fit a single screen.
*
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
* already has until the new one lands, so there is never a frame without a height. What is on
* screen is always a real prefix of the reply rather than a guess at it; it is simply one parse
* behind.
* already has until the new one lands, so there is never a frame without a height.
*/
@Composable
private fun liveSegments(text: String): List<Segment> {
@@ -187,24 +180,18 @@ private fun liveSegments(text: String): List<Segment> {
* Reparsing the whole message per delta was fine for a short reply and not for a long one: a
* twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran
* off the composing thread it was every core busy while the frame's own thread waited for one.
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it, since a paragraph ends at the blank
* line or the block that interrupts it, a fence at its closing fence, a list at the first line that
* is neither an item nor indented under one. So every block but the last is [frozen] with the parse
* that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed
* again.
*
* A list is cut once more, at its last item, by the same reasoning one level down: an item is
* finished once the next item has begun, since a line can only continue the item it is indented
* under or start a new one. Without this a reply that is one long list -- forty sources -- parsed
* the whole list per delta, and a list streams as forty paragraphs would. The item the cut lands on
* has to have begun in earnest: a bare `-` is an empty item now and the first character of a
* paragraph line once `-x` arrives, and cutting on it would draw that line as a new item.
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it. So every block but the last is
* [frozen] with the parse that finished it, and only the tail is parsed again.
*
* A list is cut once more, at its last item, by the same reasoning one level down. Without this a
* reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut
* lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of
* a paragraph line once `-x` arrives.
*
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
* it, since the frozen block's parse never sees it. The link draws as its brackets until the reply
* settles and is parsed whole by [warm], which is the same moment every other transient of
* streaming is put right.
* it. The link draws as its brackets until the reply settles and is parsed whole by [warm].
*/
private class LiveParse(
val text: String,
@@ -217,8 +204,8 @@ private class LiveParse(
get() = frozen + tail
fun advanceTo(next: String): LiveParse {
// Anything but an append to what was frozen -- a message replaced, a stream reset --
// starts over.
// Anything but an append to what was frozen -- a message replaced, a stream reset -- starts
// over.
if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
val tailText = next.substring(consumed)
val parse = parseMarkdown(tailText)
@@ -264,8 +251,7 @@ private class LiveParse(
/**
* The piece of the tail still being written: the last item of a list of several, or the first
* piece of the last block when there is more than one block. Null when nothing before it is
* finished, so the tail stays whole.
* piece of the last block when there is more than one. Null when nothing before it is finished.
*/
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
val last = all.lastOrNull() ?: return null
@@ -315,28 +301,24 @@ fun MarkdownPiece(
* The renderer's own environment -- its colours, type scale, dimensions, component table and
* reference links -- around whatever draws pieces of [parse].
*
* The parsing is the library's. Markdown is somebody else's specification, and a hand-written
* The parsing is the library's: markdown is somebody else's specification, and a hand-written
* parser would get the edge cases wrong one case at a time. So is the environment: the element
* composables its dispatch reaches read these locals, and providing them once here is what lets a
* piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
* Everything below this is the mapping onto the app's palette and type scale.
*
* The locals are provided directly rather than through the renderer's `Markdown()` composable,
* which was the last of its composables on the hot path and was here only to provide them. What
* that buys is that nothing between a piece and the screen is the library's but the leaf
* composables named in the component table, so a different parser could stand behind [State]
* without the renderer's entry point being involved.
* which was the last of its composables on the hot path and was here only to provide them. So
* nothing between a piece and the screen is the library's but the leaf composables named in the
* component table.
*
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
* Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its
* own.
*
* [streaming] says this parse is the part of a reply still being written, which only the fences
* care about: lexing is proportional to how much code there is, and a fence still arriving is
* re-lexed at every delta on the composing thread. Measured streaming a two-hundred-line Kotlin
* fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- for colours on
* text that was being replaced as fast as they were computed. So a fence still being written is
* drawn plain and takes its colours when the block freezes, which is the same bargain [LiveParse]
* already makes for a reference link defined at the foot of a message.
* care about: lexing is proportional to how much code there is. Measured streaming a two-hundred-
* line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms --
* for colours on text being replaced as fast as they were computed. So a fence still being written
* is drawn plain and takes its colours when the block freezes.
*/
@Composable
private fun MarkdownRoot(
@@ -354,45 +336,40 @@ private fun MarkdownRoot(
CompositionLocalProvider(
LocalReferenceLinkHandler provides parse.referenceLinkHandler,
LocalMarkdownPadding provides markdownPadding(),
// Read by the renderer's own text composable, which no paragraph reaches any more, and
// by its checkbox. Provided so a path that does reach them draws no image rather than
// failing to compose.
// Read by the renderer's own text composable, which no paragraph reaches any more, and by
// its checkbox. Provided so a path that does reach them draws no image rather than failing
// to compose.
LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
LocalMarkdownAnimations provides markdownAnimations(),
LocalMarkdownColors provides
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
// The dark surface every verbatim thing in this app sits on -- see [rawSurface],
// and the tool call above this reply, which now matches. `surfaceVariant` was
// exactly a card's own fill, so a fenced block inside a tool call had no
// background at all and one in a reply read as a step *up* out of the page.
// The dark surface every verbatim thing in this app sits on -- and the tool call
// above this reply, which now matches. `surfaceVariant` was exactly a card's own
// fill, so a fenced block inside a tool call had no background at all.
codeBackground = rawSurface,
// The same colour. Not drawn by the renderer as a span background but by
// [LinkedText] behind the text, so a selection lands on top of it as it does on a
// fenced block -- see `appendCodeChip`.
// [LinkedText] behind the text, so a selection lands on top of it -- see
// `appendCodeChip`.
inlineCodeBackground = rawSurface,
// The same tint a code block gets, rather than the renderer's 2%-alpha default:
// two adjacent tints that differ by a fiftieth read as one flat block on a phone,
// so the table would have had a border-less grid and nothing saying where it began.
// The same tint a code block gets, rather than the renderer's 2%-alpha default: two
// adjacent tints that differ by a fiftieth read as one flat block on a phone.
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
),
LocalMarkdownTypography provides
markdownTypography(
// A ladder that starts near the body text and descends, because these are headings
// inside a chat message rather than the top of a document. The renderer's defaults
// are the Material *display* styles -- `#` came out at 57sp and `##` at 45sp, which
// is bigger than this app's own screen titles and reads as the reply shouting.
//
// Every step is a different size, so two levels of nesting never draw the same:
// one clear step per level is the whole job of a heading.
// are the Material *display* styles -- `#` came out at 57sp, bigger than this app's
// own screen titles. Every step is a different size, so two levels of nesting never
// draw the same.
h1 = MaterialTheme.typography.headlineSmall,
h2 = MaterialTheme.typography.titleLarge,
h3 = MaterialTheme.typography.titleMedium,
h4 = MaterialTheme.typography.titleSmall,
h5 = MaterialTheme.typography.labelMedium,
h6 = MaterialTheme.typography.labelSmall,
// Body text at the size everything else in the transcript uses.
text = body,
paragraph = body,
ordered = body,
@@ -400,16 +377,10 @@ private fun MarkdownRoot(
list = body,
table = body,
// Code in a monospace face, in the ordinary text colour. The face and the tinted
// background are what say "this is code"; colour is not, and it used to be green
// -- the palette's colour for a *literal*. A block of code is not a literal, it
// is text that happens to be code, and painting all of it green said the whole
// block was one. Where a literal really does appear inside code, the thing that
// should colour it is a syntax highlighter looking at the code, which is exactly
// what a tool call's input already gets from `catppuccinSyntax`.
//
// The colour rides on the style here rather than in `markdownColor`, which
// stopped carrying `codeText`/`inlineCodeText`/`linkText` when the renderer moved
// them onto the typography.
// background are what say "this is code"; colour is not, and it used to be green --
// the palette's colour for a *literal*. A block of code is not a literal, and
// painting all of it green said the whole block was one. Where a literal really
// does appear inside code, what should colour it is a syntax highlighter.
code =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
@@ -436,31 +407,26 @@ private fun MarkdownRoot(
LocalMarkdownDimens provides
markdownDimens(
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
// the default a fifth of the narrowest column went on space rather than on words
// -- and the narrowest column is where the wrapping below has the least room.
// the default a fifth of the narrowest column went on space rather than on words.
tableCellPadding = 8.dp,
// What a column narrows to before the table starts scrolling sideways instead. It
// is the floor, not the width: a table with room to spare spreads across it.
//
// Down from the renderer's 160dp, and the number is a measurement rather than a
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
// makes even a three-column table -- the commonest shape there is -- scroll, while
// 136dp fits three across the phone this app is read on. Four and up still scroll,
// which is the right answer for genuinely too many columns: squeezing six columns
// into a phone would give every cell one word per line.
//
// Narrower would fit more, and stop being readable. This is the widest minimum
// that keeps three columns on screen, which is the trade the number is making.
// makes even a three-column table scroll, while 136dp fits three across the phone
// this app is read on. Four and up still scroll, which is the right answer for
// genuinely too many columns. This is the widest minimum that keeps three on
// screen.
tableCellWidth = 136.dp,
),
LocalMarkdownComponents provides
markdownComponents(
// The m3 renderer's own default, restored: supplying `components` at all replaces
// the whole set, and this is the only member of it the Material layer overrides.
// the whole set, and this is the only member the Material layer overrides.
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
// Everything that draws a run of text, so a link is a span rather than a node --
// see [LinkedText]. Setext headings take the same styles as `#` and `##`, which
// is the renderer's own pairing.
// see [LinkedText]. Setext headings take the same styles as `#` and `##`.
text = { LinkedText(it, it.typography.text) },
paragraph = { LinkedText(it, it.typography.paragraph) },
heading1 = { LinkedHeading(it, it.typography.h1) },
@@ -471,8 +437,8 @@ private fun MarkdownRoot(
heading6 = { LinkedHeading(it, it.typography.h6) },
setextHeading1 = { LinkedHeading(it, it.typography.h1) },
setextHeading2 = { LinkedHeading(it, it.typography.h2) },
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote --
// so they draw like the top-level ones the transcript cuts into items.
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so
// they draw like the top-level ones the transcript cuts into items.
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = { LinkedTable(it.content, it.node, it.typography.table) },
@@ -491,14 +457,12 @@ private fun MarkdownRoot(
/**
* A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
*
* Each column has a floor ([markdownDimens]'s `tableCellWidth`), so the table is at least
* columns-times-floor wide; narrower than the room it has, it spreads to fill it, and wider, it
* scrolls sideways rather than squeezing. The renderer decided that with a `BoxWithConstraints`,
* which is a subcomposition; here it is one layout modifier, and the trick is where it sits.
* `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that
* minimum through to its content while lifting the maximum to unbounded, and the modifier after it
* reads the minimum back as the room and sizes the rows to the larger of that and the floor. The
* scroll then has exactly the overflow to scroll, which is none when the table fits.
* Each column has a floor, so the table is at least columns-times-floor wide; narrower than the
* room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The
* renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one
* layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal
* scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after
* it reads the minimum back and sizes the rows to the larger of that and the floor.
*/
@Composable
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
@@ -539,19 +503,15 @@ private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
* One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText].
*
* The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
* most of a table is simply not readable: anything past about twenty characters ends in "..." with
* no way to see the rest, and an elided cell looks like a short one, so a table of measurements
* reads as a table of plausible shorter measurements. And they draw a link in a cell as its own
* layout node, the cost [LinkedText] exists to avoid.
* most of a table is simply not readable: an elided cell looks like a short one, so a table of
* measurements reads as a table of plausible shorter measurements. And they draw a link in a cell
* as its own layout node, the cost [LinkedText] exists to avoid.
*
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
* beside a one-line one centred the short one against the middle of the tall one and lost the line
* the reader was reading across. What the wrapping does *not* do is make a wide table fit;
* [LinkedTable] scrolls it instead, which is the right answer for too many columns -- wrapping a
* six-column table into the width of a phone would give every cell one word per line.
* beside a one-line one centred the short one against the middle of the tall one. What the wrapping
* does *not* do is make a wide table fit; [LinkedTable] scrolls it instead.
*
* The semantics are the renderer's: each cell is an item of the table's collection, and a header
* cell is a heading.
* The semantics are the renderer's: each cell is an item of the table's collection.
*/
@Composable
private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
@@ -587,19 +547,14 @@ private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowI
* Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much
* was written. Measured against a real Claude Code transcript on the emulator, one message took
* **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first
* tuned on -- so a page of history landing composed several rows that each stalled the frame they
* appeared in. That is the lag when a block loads.
* tuned on -- so a page of history landing composed several rows that each stalled the frame.
*
* Nothing here changes what a row does when it has no answer waiting: it parses inline, on the
* composing thread, because a row measured at nothing before it is measured at its real height
* collapses the transcript above it. The point is only that by the time the reader scrolls to a
* row, the answer is usually already made -- [warm] runs on a background thread as each page of
* history arrives, which is seconds before anybody reaches the rows it brought.
* Nothing here changes what a row does when it has no answer waiting: it parses inline, because a
* row measured at nothing before its real height collapses the transcript above it. The point is
* only that by the time the reader scrolls to a row, the answer is usually already made.
*
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page
* warmed and nothing else, so a reply still streaming cannot fill it with hundreds of copies of
* itself on the way to being finished. It is dropped with the screen, and emptied by the stream
* reset that drops the rows it describes.
* warmed, so a reply still streaming cannot fill it with hundreds of copies of itself.
*/
@Stable
class ParsedReplies {
@@ -607,14 +562,13 @@ class ParsedReplies {
/**
* How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per
* fold, and walking the tree again each time is proportional to the message where a lookup is
* proportional to nothing.
* fold, and walking the tree again each time is proportional to the message.
*/
private val pieces = ConcurrentHashMap<String, List<Piece>>()
/**
* How each message divides into prose and memory notes, cached for the same reason as
* [piecesOf]: the regex scan behind [messageParts] is proportional to the message.
* How each message divides into prose and memory notes, cached for the same reason: the regex
* scan behind [messageParts] is proportional to the message.
*/
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
@@ -627,7 +581,7 @@ class ParsedReplies {
* much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator --
* and a lazy list drops the composition of a block that scrolls away, so a `remember` inside
* the fence paid that again every time the reader came back to it. Six times in one scroll,
* measured. [warm] fills this off the drawing thread before the row is reached.
* measured.
*/
private val highlights = ConcurrentHashMap<String, AnnotatedString>()
@@ -649,11 +603,10 @@ class ParsedReplies {
* Whether [warm] has made everything drawing [text] as pieces will look up.
*
* What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole
* message and the flatten runs on the composing thread -- so a reply not marked yet stays
* whole, drawing the parse it already has, until the screen has warmed it and re-flattens. An
* explicit mark rather than a peek into the parse cache, because a message with memory notes is
* warmed as its *parts*: nothing ever parses its full text, and inferring readiness from the
* cache left exactly that message unsplittable forever, re-warmed on every fold.
* message and the flatten runs on the composing thread, so a reply not marked yet stays whole
* until the screen has warmed it. An explicit mark rather than a peek into the parse cache,
* because a message with memory notes is warmed as its *parts*: nothing ever parses its full
* text, and inferring readiness from the cache left exactly that message unsplittable forever.
*/
fun splitReady(text: String): Boolean = text in ready
@@ -668,9 +621,8 @@ class ParsedReplies {
}
/**
* [code] coloured for [language] -- the answer made ahead, or one made now.
*
* The key carries the language, because the same code lexes differently under two of them.
* [code] coloured for [language] -- the answer made ahead, or one made now. The key carries the
* language, because the same code lexes differently under two of them.
*/
fun highlighted(code: String, language: Language?): AnnotatedString =
if (language == null) AnnotatedString(code)
@@ -686,9 +638,9 @@ class ParsedReplies {
*
* Suspending, and yielding between messages, because "off the composing thread" is not the same
* as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in
* a twelve second scroll, measured on a Pixel 9 Pro XL -- and on the default dispatcher that is
* every core busy, with the frame's own thread waiting for one. That showed up as 21ms of
* `waited` at the 90th percentile: the frame could not start, rather than taking too long.
* a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every
* core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th
* percentile.
*/
suspend fun warm(texts: List<String>) {
texts.forEach { text ->
@@ -697,9 +649,8 @@ class ParsedReplies {
DebugStats.timed("markdown warmed") { parseMarkdown(it) }
}
// The fences too, and here rather than in a pass of its own: they are found in the
// parse this just made, and lexing one is the same kind of cost as parsing the
// message it is in -- proportional to what was written, and charged to the frame
// that first draws it if nobody paid it earlier.
// parse this just made, and lexing one is the same kind of cost as parsing the message
// it is in.
fences(parse).forEach { (code, language) -> highlighted(code, language) }
}
}
@@ -32,6 +32,7 @@ import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.utils.getUnescapedTextInNode
import com.mikepenz.markdown.utils.resolveImageAlt
import com.mikepenz.markdown.utils.resolveImageLink
import java.net.URI
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
@@ -44,27 +45,22 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
*
* Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable,
* hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one of those
* annotations per link. Measured on the emulator against the same paragraphs with each link
* replaced by its label and address as plain words -- *more* text, the same gestures -- the linked
* version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a
* Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it.
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation
* per link. Measured on the emulator against the same paragraphs with each link replaced by its
* label and address as plain words -- *more* text, the same gestures -- the linked version cost
* five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time.
*
* Here a link is the link colour and underline, a string annotation carrying its address, and one
* tap detector for the whole text that asks the layout which character was under the finger. What
* that gives up is a link being its own accessibility node with a pressed state; the app's link
* style never defined a pressed style, so nothing visible changes.
*
* Every block the renderer dispatches through its component table comes here, which includes the
* paragraphs inside lists, quotes and alerts, and so does every table cell through
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
* resolves those against its definitions.
* Every block the renderer dispatches through its component table comes here, and so does every
* table cell. Reference-style links are the one kind still drawn the renderer's way.
*
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
* model put something, with no sign of what fell out. The link says what was there and where, and
* opens it. It also means no paragraph needs the renderer's own text composable, which existed to
* place inline images and charged every paragraph for the possibility.
* model put something. The link says what was there and where, and opens it.
*/
@Composable
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
@@ -74,8 +70,7 @@ fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
/**
* A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
* `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
* does not know, so handed the heading node itself it draws an empty line. Which is what this did
* for a week.
* does not know, so handed the heading node itself it draws an empty line.
*/
@Composable
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
@@ -95,6 +90,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
content.buildMarkdownAnnotatedString(node, style, settings)
}
val uriHandler = LocalUriHandler.current
val fileLinkHandler = LocalFileLinkHandler.current
val onPlainTap = LocalMarkdownTap.current
val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour.
@@ -113,18 +109,18 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
BasicText(
text = text,
modifier =
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the
// second one has to be answered from inside the text rather than left to the card.
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second
// one has to be answered from inside the text rather than left to the card.
modifier.then(chipFill).pointerInput(text, onPlainTap) {
awaitEachGesture {
// Unconsumed is not required: something outside may already be tracking this
// press, and it is still the press that may land on a link.
awaitFirstDown(requireUnconsumed = false)
// A tap and nothing else. Null when the gesture became something somebody
// else's -- a scroll, or a press held past the long-press timeout, which is
// how a selection starts. The timeout is the load-bearing half: without it a
// press held for a second and released was still an up with nothing consumed,
// so holding a peer message to select from it shut the card instead.
// A tap and nothing else. Null when the gesture became somebody else's -- a
// scroll, or a press held past the long-press timeout, which is how a selection
// starts. The timeout is the load-bearing half: without it a press held for a
// second and released was still an up with nothing consumed, so holding a peer
// message to select from it shut the card instead.
val up =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
@@ -133,7 +129,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
when {
url != null -> {
up.consume()
uriHandler.openUri(url)
if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url)
}
onPlainTap != null -> {
up.consume()
@@ -156,27 +152,73 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
* usually -- or null where a plain tap means nothing.
*
* A composition local because there is nowhere else to put it. The paragraphs of a message are
* composed by the renderer's own dispatch out of its component table, so nothing between a card and
* the text inside it is ours to pass a parameter through; the renderer already hands its colours,
* its typography and its components down the same way.
* composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours
* to pass a parameter through.
*
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click
* handler never sees it. Measured on the emulator against an opened peer message: with a handler on
* the text -- consuming or not -- a tap on its words did nothing at all, and with the handler
* removed entirely the same tap shut the card. So a card whose body is markdown cannot be shut by
* pressing its words unless the words do the shutting, and "nothing happens when I press it" is
* indistinguishable from a card that has stopped working.
* handler never sees it. Measured against an opened peer message: with a handler on the text --
* consuming or not -- a tap on its words did nothing at all, and with the handler removed the same
* tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless
* the words do the shutting.
*
* Provided as a value that outlives a recomposition (see [rememberMarkdownTap]), since a fresh
* lambda per composition would invalidate every paragraph reading it.
* Provided as a value that outlives a recomposition, since a fresh lambda per composition would
* invalidate every paragraph reading it.
*/
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* [onTap] as a stable value to provide for [LocalMarkdownTap].
* 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.
*
* The identity stays put while the behaviour follows the latest [onTap], which is what keeps
* providing it from invalidating the text under it on every recomposition of the card.
* 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
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
* under it on every recomposition of the card.
*/
@Composable
fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit {
@@ -212,9 +254,8 @@ private const val LINK_URL = "url"
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
* background is part of the text's own drawing, and the text node draws the selection first and the
* glyphs over it, so a chip painted as a span background covered the selection: selecting a
* sentence highlighted every word of it except the ones in backticks. Anything drawn by a modifier
* on the text is under both, which is where a fenced block's box already is and why one of those
* always looked right. The [CODE_CHIP] annotation is what says where the fill goes.
* sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the
* text is under both, which is where a fenced block's box already is.
*/
private fun appendCodeChip(
builder: AnnotatedString.Builder,
@@ -242,13 +283,11 @@ private const val CODE_CHIP = "code"
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
* stops being drawn: the same rule the selection rectangle obeys, so the two agree rather than the
* chip sticking a space out past the end of a selected line. It is also what leaves nothing behind
* when the only thing to reach a line is the space a chip is padded with.
* stops being drawn -- the same rule the selection rectangle obeys, so the two agree.
*
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
* line reads in one direction; mixed directions inside a code span would draw one box across the
* whole run rather than one per direction, and code spans are code.
* whole run, and code spans are code.
*/
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
val rects = mutableListOf<Rect>()
@@ -34,22 +34,18 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
*
* The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
* is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
* hundred short ones; and the list composes an item whole in the frame it scrolls into, so an item
* has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still
* being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a
* table, one bullet: bounded, so both costs are.
* hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on
* a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one
* message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are.
*
* Cut where the parser says the blocks are, which is the whole reason this is safe: a fence, a
* table and a nested list are each one node whatever is inside them, so nothing is ever split down
* the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty
* items -- so it is cut once more, into its items, and a nested list stays inside the item that
* holds it.
* Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a
* nested list are each one node whatever is inside them. A list is the one block that is not
* bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its
* items.
*
* A piece is an *address* into the message's one parse ([block] indexes the root's children, [item]
* the list items of that child) rather than a substring of the message. Every piece of a message is
* drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a
* reference definition at its foot still resolves the links above it -- the two costs of cutting a
* message into strings and parsing each on its own.
* A piece is an *address* into the message's one parse rather than a substring of it. Every piece
* is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and
* a reference definition at its foot still resolves the links above it.
*/
@Immutable
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
@@ -59,8 +55,7 @@ data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
}
/**
* The pieces of [parse], in reading order. Blank nodes between blocks -- the parser keeps the
* newlines -- are not pieces.
* The pieces of [parse], in reading order. Blank nodes between blocks are not pieces.
*
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
* message that drew as nothing would be a hole in the transcript with no sign of what fell out.
@@ -90,17 +85,15 @@ fun gapBefore(previous: Piece?, piece: Piece): Dp =
val BLOCK_SPACING: Dp = 6.dp
/**
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which is what carries the
* theme, the components and the reference links to the renderer's element composables.
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme,
* the components and the reference links to the renderer's element composables.
*
* A whole block goes to the renderer's own dispatch with this app's component table, so a paragraph
* or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here
* through [MarkdownList]. Only the list item is drawn directly, because a list item is the one
* piece the renderer has no element for.
* A whole block goes to the renderer's own dispatch with this app's component table. Only the list
* item is drawn directly, because a list item is the one piece the renderer has no element for.
*
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply (see
* `LiveParse`): an item that is the first or last of its own parse but not of the list the reader
* sees keeps an inner item's padding, so nothing moves when the seam between segments does.
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an
* item that is the first or last of its own parse but not of the list the reader sees keeps an
* inner item's padding, so nothing moves when the seam between segments does.
*/
@Composable
fun MarkdownPiece(
@@ -112,8 +105,8 @@ fun MarkdownPiece(
listContinues: Boolean = false,
) {
if (parse !is State.Success) {
// The parser threw. Nothing else in the app has seen this happen; if it does, the words
// are still worth more than a blank.
// The parser threw. Nothing else in the app has seen this happen; if it does, the words are
// still worth more than a blank.
Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
return
}
@@ -144,8 +137,7 @@ fun MarkdownPiece(
/**
* A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
* list inside a quote, and the nested lists an item holds. Top-level lists never come here; they
* are drawn an item at a time as pieces.
* list inside a quote, and the nested lists an item holds. Top-level lists never come here.
*/
@Composable
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
@@ -170,9 +162,8 @@ fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier
* list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
* and last items, since there is no list column to carry it.
*
* The marker is the renderer's bullet and number, and a checkbox for a task item. It is drawn here
* rather than by a handler because it is the thing a reader might one day want styled -- a
* different glyph per depth, a colour -- and this is the one place it is drawn.
* The marker is drawn here rather than by a handler because it is the thing a reader might one day
* want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn.
*/
@Composable
private fun MarkdownListItem(
@@ -231,8 +222,8 @@ private fun Marker(text: String, style: TextStyle) {
/**
* The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a
* browser draws, so a nested list is told from its parent by the glyph as well as by the indent.
* Checked on the emulator's system fonts, which is what makes them safe to rely on; a glyph the
* platform lacks draws as a box, and that check is the price of adding one here.
* Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check
* is the price of adding one here.
*/
private val BULLETS = listOf("", "", "")
@@ -7,23 +7,19 @@ package com.example.aiapp
* Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings:
* what a character means depends on where it sits. A `#` opens a heading at the start of a line and
* is an ordinary character three words in; a `*` opens emphasis only if something closes it on the
* same line. The token scanner cannot ask either question, and answering them with its rules is how
* a highlighter comes to grey out the second half of a paragraph.
* same line. The token scanner cannot ask either question.
*
* Structure is read a line at a time and each line's prose is then read left to right, so every
* decision is made inside one line -- except the two things that are not one line. A fenced block
* is state carried forward, so an unclosed fence colours the rest of the text, which is also what
* it looks like while somebody is still writing it. A table is found by its delimiter row
* (`|---|---|`), which is the only line of one that cannot be anything else, and its header is the
* line before that -- the one place here that looks ahead.
* Structure is read a line at a time and each line's prose left to right, so every decision is made
* inside one line -- except the two that are not. A fenced block is state carried forward, so an
* unclosed fence colours the rest of the text, which is what it looks like while somebody is
* writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot
* be anything else, and its header is the line before that -- the one place here that looks ahead.
*
* What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is
* one, and four spaces after a bullet is a list item's second paragraph, and the two are told apart
* by what came before rather than by the line itself. Colouring the wrong one of those as code is a
* mistake the reader cannot see, so both are left plain, which is the safe answer.
* one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by
* what came before. Colouring the wrong one as code is a mistake the reader cannot see.
*
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction:
* every one is emitted by a pass that only moves forward, and nothing here throws.
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction.
*/
fun scanMarkdown(code: String): List<Span> = MarkdownScanner(code).run()
@@ -36,7 +32,7 @@ private const val RULE_MARKERS = "-*_="
/** The characters that can open emphasis, strong emphasis or a strikethrough. */
private const val EMPHASIS = "*_~"
/** Characters that end a bare URL wherever they appear in it, and ones only trimmed off the end. */
/** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */
private const val URL_STOPS = "<>\"'`|"
private const val URL_TRAILING = ".,:;!?"
@@ -53,8 +49,8 @@ private class MarkdownScanner(private val code: String) {
val end = lineEnd(at)
val open = fence
if (open != null) {
// The content and the closing line alike: a fence is one block of code, and its
// own delimiters belong to it the way a string's quotes belong to the string.
// The content and the closing line alike: a fence is one block of code, and its own
// delimiters belong to it the way a string's quotes belong to the string.
emit(at, end, Kind.STRING)
if (closesFence(at, end, open)) fence = null
} else {
@@ -77,11 +73,10 @@ private class MarkdownScanner(private val code: String) {
/**
* One line that is not inside a fence, and whether the table it may be part of is still open.
*
* A table is recognised by its delimiter row (`|---|---|`), which is the only line of one that
* cannot be anything else. That row comes *after* the header it belongs to, so the header is
* found by looking one line ahead -- the single piece of lookahead here, and cheaper than the
* alternative of colouring every `|` in the document, which would mark the pipes in a shell
* command written in a paragraph.
* A table is recognised by its delimiter row, the only line of one that cannot be anything
* else. That row comes *after* the header it belongs to, so the header is found by looking one
* line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the
* document, which would mark the pipes in a shell command written in a paragraph.
*/
private fun row(start: Int, end: Int, table: Boolean): Boolean {
if (tableDelimiter(start, end)) {
@@ -142,10 +137,9 @@ private class MarkdownScanner(private val code: String) {
}
/**
* Spans, coalesced with the one before when they touch and agree.
*
* Worth doing here rather than leaving it to the caller: the line scanner emits per marker and
* per word, so a heading would otherwise arrive as a dozen abutting spans of one colour.
* Spans, coalesced with the one before when they touch and agree. Worth doing here rather than
* leaving it to the caller: the line scanner emits per marker and per word, so a heading would
* otherwise arrive as a dozen abutting spans of one colour.
*/
private fun emit(start: Int, end: Int, kind: Kind) {
if (end <= start) return
@@ -179,17 +173,16 @@ private class MarkdownScanner(private val code: String) {
private fun opensFence(start: Int, end: Int): String? {
val run = fenceRun(start, end) ?: return null
emit(run.first, run.last + 1, Kind.STRING)
// The info word is what the fence is a fence *of*, which is metadata about the block
// rather than part of it -- the same reading as a Rust attribute above a struct.
// The info word is what the fence is a fence *of*, which is metadata about the block rather
// than part of it.
emit(indented(run.last + 1, end), end, Kind.METADATA)
return code.substring(run.first, run.last + 1)
}
/**
* Whether this line closes a fence opened by [open].
*
* The same character, at least as many of them, and nothing else on the line -- so a longer run
* closes a shorter one and a line of backticks with a word after it does not close anything.
* Whether this line closes a fence opened by [open]: the same character, at least as many of
* them, and nothing else on the line -- so a longer run closes a shorter one and a line of
* backticks with a word after it does not close anything.
*/
private fun closesFence(start: Int, end: Int, open: String): Boolean {
val run = fenceRun(start, end) ?: return false
@@ -227,10 +220,9 @@ private class MarkdownScanner(private val code: String) {
* A line made of one repeated rule character and nothing else.
*
* `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a
* setext heading. The two are the same line to look at and mean the same thing to a reader -- a
* rule drawn across the page -- so they get one appearance rather than a lookback to tell them
* apart. One `=` is enough because a setext underline may be a single character; a break needs
* three, which is what keeps a `- ` bullet out of here.
* setext heading. The two are the same line to look at and mean the same thing to a reader, so
* they get one appearance rather than a lookback. One `=` is enough because a setext underline
* may be a single character; a break needs three, which keeps a `- ` bullet out of here.
*/
private fun thematicBreak(start: Int, end: Int): Boolean {
val marker = code[start]
@@ -292,10 +284,9 @@ private class MarkdownScanner(private val code: String) {
}
/**
* `` `code` ``, closed by a run of exactly as many backticks as opened it.
*
* That count is what lets a span hold a backtick of its own (``` ``a ` b`` ```), and it is why
* the search skips over a shorter or longer run rather than stopping at the first backtick.
* `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what
* lets a span hold a backtick of its own, and why the search skips over a shorter or longer run
* rather than stopping at the first backtick.
*/
private fun codeSpan(start: Int, end: Int): Int {
var open = start
@@ -323,9 +314,8 @@ private class MarkdownScanner(private val code: String) {
* `[text](destination)`, and the same with a leading `!` for an image.
*
* The text is drawn as prose -- it is what the reader reads -- so only the brackets around it
* are marked, and the destination is metadata: the place the link goes rather than anything
* said to the reader. A `[text]` with no destination after it is left plain, because that is
* what a reference link and a bracketed aside look like, and neither is worth guessing at.
* are marked, and the destination is metadata. A `[text]` with no destination after it is left
* plain, because that is what a reference link and a bracketed aside look like.
*/
private fun link(start: Int, bracket: Int, end: Int): Int {
var depth = 0
@@ -357,8 +347,7 @@ private class MarkdownScanner(private val code: String) {
* `<https://example.com>` and `<name@example.com>`, drawn as the destination they are.
*
* The angle brackets have to hold no whitespace and something that makes an address of it -- a
* scheme's colon or an at sign -- which is what keeps an HTML tag out: `<div>` has neither, and
* `<img src="http://x">` has the colon but also a space.
* scheme's colon or an at sign -- which is what keeps an HTML tag out.
*/
private fun autolink(start: Int, end: Int): Int {
var at = start + 1
@@ -380,13 +369,12 @@ private class MarkdownScanner(private val code: String) {
/**
* A bare `scheme://…` written in prose, or null if one does not start here.
*
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry, and
* the pair of colons is what makes the match unambiguous enough to draw without a closer.
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry.
*
* Where it ends is the part worth stating: the sentence's punctuation is not the address, so a
* trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the
* URL -- otherwise a link in parentheses loses its `)` to the address. A pipe stops it too,
* because a URL in a table cell must not swallow the cell's edge.
* URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a
* table cell must not swallow the cell's edge.
*/
private fun url(start: Int, end: Int): Int? {
if (start > 0 && isWord(code[start - 1])) return null
@@ -415,14 +403,13 @@ private class MarkdownScanner(private val code: String) {
}
/**
* `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all.
* `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the
* token scanner draws a string: the quotes are part of the thing.
*
* Markers and all because that is how the token scanner draws a string: the quotes are part of
* the thing. The two guards are what keep this off code that happens to be in a paragraph --
* the opener must be followed by something to emphasise and the closer preceded by something
* emphasised, so `a * b * c` opens nothing and neither does the `*p = *q` of a C fragment.
* Underscores additionally may not start or end inside a word, or every `snake_case_name` in a
* document would be half emphasised.
* The two guards keep this off code that happens to be in a paragraph: the opener must be
* followed by something to emphasise and the closer preceded by something emphasised, so `a * b
* * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start
* or end inside a word, or every `snake_case_name` would be half emphasised.
*/
private fun emphasis(start: Int, end: Int): Int {
val marker = code[start]
@@ -25,12 +25,11 @@ import androidx.compose.ui.unit.dp
* Claude Code marks a sentence that came from its stored memory by wrapping it in `<cc-memory
* filenames="...">`. Markdown has nothing to say about that, so it arrived on screen as literal
* angle brackets in the middle of a sentence -- which reads as the model having emitted broken
* HTML. It is really the opposite: a claim about where something came from, which is worth showing,
* because "I was told this before" and "I worked this out just now" are different things and the
* reader cannot otherwise tell them apart.
* HTML. It is really the opposite: a claim about where something came from, and "I was told this
* before" and "I worked this out just now" are different things the reader cannot otherwise tell
* apart.
*
* A tag that has not finished arriving is left alone. Streaming means the closing tag may be
* seconds away, and a half-written marker is not a marker yet.
* A tag that has not finished arriving is left alone: a half-written marker is not a marker yet.
*/
@Composable
fun AssistantMessage(
@@ -66,12 +65,10 @@ fun AssistantMessage(
* A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed
* prose part made while looking for them -- inspecting a message must not change it. That belongs
* here rather than at the places that need the answer, because [warm] has to name the same strings
* the rows draw: a string warmed under a key no row ever looks up is a miss that nothing reports,
* and the row pays the parse in the frame it appears, which is the cost being removed.
* the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports.
*
* Public because [transcriptUnits] flattens settled replies into the same parts; go through
* [ParsedReplies.partsOf] on any path that runs per fold or per page, so the scan happens once per
* message.
* [ParsedReplies.partsOf] on any path that runs per fold or per page.
*/
fun messageParts(text: String): List<MessagePart> {
val parts = splitMemoryNotes(text)
@@ -83,16 +80,14 @@ fun messageParts(text: String): List<MessagePart> {
*
* Closed by default, like a tool call and a peer message and for the same reason: it is not part of
* what was said to the reader, it is a note about where a claim came from. Left open it breaks the
* reply in half around a card, which reads as the answer having stopped and restarted -- and these
* arrive several to a message.
* reply in half around a card, and these arrive several to a message.
*
* What stays visible is which file it came from, because that is the whole of what the note claims
* and it is the part a reader scanning for "why does it think that" is looking for.
* and the part a reader scanning for "why does it think that" is looking for.
*
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
* still be open on the way back, and a card that remembered for itself would forget the moment the
* list stopped composing it. The text is a good enough name -- it does not change once the closing
* tag has arrived, so a note stays open across the moment its reply settles.
* list stopped composing it.
*/
@Composable
fun MemoryNote(
@@ -148,10 +143,8 @@ private val MEMORY_NOTE =
Regex("""<cc-memory\s+filenames="([^"]*)"\s*>(.*?)</cc-memory>""", RegexOption.DOT_MATCHES_ALL)
/**
* Splits [text] into prose and memory notes, in order.
*
* Always returns at least one part, so a message with no notes in it is one piece of prose and
* costs nothing extra to draw.
* Splits [text] into prose and memory notes, in order. Always returns at least one part, so a
* message with no notes is one piece of prose and costs nothing extra to draw.
*/
fun splitMemoryNotes(text: String): List<MessagePart> {
val parts = mutableListOf<MessagePart>()
@@ -5,31 +5,41 @@ package com.example.aiapp
*
* One constant rather than a literal in each place, because the two have to agree: a picker whose
* options cannot say every state its button can display is one you can leave and not get back to.
* It is also the Claude CLI's own word for "whatever is configured", so choosing it is a request
* the session can act on rather than a name this app made up.
* It is also the Claude CLI's own word for "whatever is configured".
*/
const val DEFAULT_MODEL = "default"
/**
* A model's name as a person reads it.
*
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to
* `claude-haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session
* using" and far too long for a button in a row that also has to hold Stop and Send.
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude-
* haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using"
* and far too long for a button in a row that also holds Stop and Send.
*
* So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which
* is the same on every model this app can show, and the release date, which distinguishes builds of
* one model rather than one model from another. What is left is the part somebody chose --
* `haiku-4-5` -- and anything that does not look like that is returned untouched, since a name this
* does not recognise is a name it has no business editing.
* one model rather than one model from another. Anything that does not look like that is returned
* untouched.
*
* A display decision, not a correction: the full name is what the session reports and what a reader
* is shown when there is room for it.
* A 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.
*/
fun modelLabel(model: String?): String {
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, "")
}
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
private val DATED_SUFFIX = Regex("""-\d{8}$""")
/** What every model a llama.cpp session can run is stored as. */
private const val GGUF = ".gguf"
@@ -1,381 +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.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. Slow enough not
// to matter, frequent enough that a bar moves.
// Keyed on the token as well, so the header's Refresh restarts the loop with a read now
// rather than leaving the reader watching for up to a second and a half to see whether
// anything happened.
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 of what is happening.
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 -- the server joins the running
// download rather than starting a second.
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)
}
@@ -26,26 +26,21 @@ import androidx.compose.ui.unit.sp
* set and kept in step by hand.
*
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
* grounds that a system font may not have the glyph and whoever gets the empty box instead is never
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
* right: the answer is not to avoid glyphs but to ship them. The font here is
* `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
* font and committed -- so the codepoints below are resolved by an asset in the APK and cannot come
* back as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the
* script did not subset is a glyph that silently isn't there.
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
* is `app/build-icon-font.sh`'s output -- eighteen glyphs, 2.9 KB, subset out of the 3 MB symbols
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
* the script did not subset is a glyph that silently isn't there.
*
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
* That is what makes two icons the same size without either of them being given a size: the
* proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side
* by side came out visibly different widths, and matching them at the call site would have meant
* one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost.
* That is what makes two icons the same size without either being given a size: the proportional
* face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came
* out visibly different widths. [GLYPH_SIZE] carries the cost.
*
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app
* and something else in the other is the failure this is worth preventing. The script is copied
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to
* differ -- the point of subsetting is to ship only the codepoints one app draws. All Material
* Design bar one, so they read as one family; the exception is noted where it is declared.
* Material Design codepoints. Those two must not drift. The script is copied rather than shared
* because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the
* point of subsetting is to ship only the codepoints one app draws.
*/
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
@@ -74,8 +69,7 @@ val STOP_GLYPH = glyph(0xF04DB)
*
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
* pressing it now would do to the process, and the three marks are the three answers. An interrupt
* ends a turn and nothing else -- the CLI is still there and still holds the conversation -- which
* is a pause, not a stop, and drawing it as a square said otherwise.
* ends a turn and nothing else, which is a pause, not a stop.
*/
val PAUSE_GLYPH = glyph(0xF03E4)
@@ -86,8 +80,8 @@ val PLAY_GLYPH = glyph(0xF040A)
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
*
* The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than
* starting one, and the two buttons have to be told apart at a glance -- one glyph doing both jobs
* while looking identical would promise something immediate and do something that waits.
* starting one, and one glyph doing both jobs would promise something immediate and do something
* that waits.
*/
val QUEUE_GLYPH = glyph(0xF1163)
@@ -104,8 +98,7 @@ val BELL_GLYPH = glyph(0xF009A)
* `fa-line_chart` -- how much of the account's rate limits is gone.
*
* Font Awesome's rather than Material's, which is the one break in the family above: it was asked
* for by name, and Material's chart glyphs are a bare line where this one has its axes, which is
* what makes it read as a measurement rather than as a trend.
* for by name, and Material's chart glyphs are a bare line where this one has its axes.
*/
val USAGE_GLYPH = glyph(0xF201)
@@ -121,9 +114,8 @@ val SPEED_GLYPH = glyph(0xF04C5)
* `md-folder` -- the files on the machine this session runs on.
*
* The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and
* the refresh arrow must not: a folder that meant something else in one of the two apps is exactly
* the confusion sharing them prevents. Doubles as the mark on a directory row inside the explorer,
* which is what makes the button say where it leads.
* the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is
* what makes the button say where it leads.
*/
val FOLDER_GLYPH = glyph(0xF024B)
@@ -144,14 +136,41 @@ val EDIT_GLYPH = glyph(0xF03EB)
*/
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.
*
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
* most 0.83 em of its point size and most filled a good deal less, so the number was standing in
* for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and
* keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked
* for. This is what the largest of them already drew at.
* most 0.83 em of its point size, so the number was standing in for the headroom above the tallest
* one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every
* icon in the app up by a fifth.
*/
private val GLYPH_SIZE = 17.sp
@@ -165,28 +184,24 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
*
* The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to
* the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of
* its own, and a mark cannot end up further from the button beside it than from the edge of the
* screen. That is what it was: the box was the size of the mark (28dp) and the separation was
* its own. That is what it was: the box was the size of the mark (28dp) and the separation was
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
* edge, so a pair that acts on one screen read as two unrelated marks with one falling off it.
* edge.
*
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
* to find. It is what the pressed-state ripple draws, too: at 28dp that circle was inscribed in the
* mark's own corners, and beside a title it arrived at the first letter. And it is taller than any
* header's text, which is what lets the button fill a header row rather than sit in the middle of
* one -- the rows add no vertical padding of their own for the same reason they add no gap.
* to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
* text, which is what lets the button fill a header row rather than sit in the middle of one.
*/
private val GLYPH_BUTTON_SIZE = 48.dp
val GLYPH_BUTTON_SIZE = 48.dp
/**
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
* a back arrow.
*
* Two glyph buttons need nothing between them: each brings its own ring and the two add up, which
* is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for.
* Without it the pressed-state circle, which fills the whole square, arrives at the first letter of
* the title -- and the gap a reader sees between the mark and that title is then half the one
* between the two marks at the other end of the same row.
* Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text
* brings none, so the second ring has to be asked for -- without it the pressed-state circle
* arrives at the first letter of the title.
*/
val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
@@ -195,8 +210,7 @@ val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
*
* Its own composable so that every icon button in the app is one size and one colour without each
* caller saying so, and so the [label] none of them displays is still there for a screen reader --
* which is all assistive technology has to go on, and also the answer to "what was that button for"
* six months from now.
* which is also the answer to "what was that button for" six months from now.
*
* [enabled] is passed through rather than left to callers hiding the button: a control that comes
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
@@ -220,9 +234,8 @@ fun GlyphButton(
* The same square, around a mark that is not a glyph.
*
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
* size, spacing and touch target every other icon button on this app's headers already is -- so
* this is [GlyphButton] with the mark left to the caller rather than a second set of measurements
* beside it. The caller still owes it a [label]: nothing here draws a word.
* size, spacing and touch target every other icon button already is. The caller still owes it a
* [label]: nothing here draws a word.
*/
@Composable
fun MarkButton(
@@ -245,9 +258,7 @@ fun MarkButton(
* The square a glyph button occupies, with a spinner in it instead of a mark.
*
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so
* swapping one for the other leaves everything in the row exactly where it was -- a control that
* changed the width of its header while it worked would move its neighbours at the moment somebody
* was pressing them.
* swapping one for the other leaves everything in the row exactly where it was.
*/
@Composable
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
@@ -273,10 +284,9 @@ fun Glyph(
size: TextUnit = GLYPH_SIZE,
) {
// Line height of the point size, which for this font is the square the glyph draws in: its
// ascent and descent add up to exactly one em, and every glyph in the Mono face fills that em.
// Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a
// glyph took a seventh more vertical space than horizontal wherever one is drawn without a box
// around it -- and where there is a box, that leading is what its padding is measured through.
// ascent and descent add up to exactly one em. Left to the inherited body style the line box
// was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than
// horizontal.
Text(
glyph,
fontFamily = NerdIcons,
@@ -34,12 +34,13 @@ import org.json.JSONObject
* gets a push from Google's servers, which would mean this backend talking to Google about
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
*
* The cost Android charges for it is a notification of its own that cannot be dismissed. That is
* made as quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no
* sound, shows no status-bar icon, and sits at the bottom of the shade -- the same arrangement
* Syncthing's "hide the persistent notification" option produces. It is not hidden outright,
* because it cannot be and because it should not be: it is the honest indicator that something is
* holding a connection open.
* 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
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
* cannot be and because it should not be: it is the honest indicator that something is holding a
* connection open.
*/
class NotificationService : Service() {
@Volatile private var stream: HttpURLConnection? = null
@@ -50,18 +51,18 @@ class NotificationService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val settings = loadServerSettings(this)
if (settings == null) {
// Nothing to connect to. Stopping rather than idling: a service
// holding no connection still costs the ongoing notification,
// which would then be announcing work that is not happening.
// Nothing to connect to. Stopping rather than idling: a service holding no connection
// still costs the ongoing notification, which would be announcing work that is not
// happening.
stopSelf()
return START_NOT_STICKY
}
// Through ServiceCompat so the type is stated once and ignored on
// the versions that predate types, rather than branching here.
// Through ServiceCompat so the type is stated once and ignored on the versions that predate
// types, rather than branching here.
ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType())
thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) }
// Restarted if Android kills it, which is the whole point: the
// window this covers is exactly the one where nobody is watching.
// Restarted if Android kills it, which is the whole point: the window this covers is
// exactly the one where nobody is watching.
return START_STICKY
}
@@ -73,11 +74,10 @@ class NotificationService : Service() {
/**
* Follows the backend's notification stream, reconnecting until stopped.
*
* A dropped connection is the ordinary case here rather than an error -- a phone changes
* networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and
* forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you
* whether anything happened" on a phone in somebody's pocket is noise about a condition they
* cannot act on, and the session list already says what is waiting when they next look.
* A dropped connection is the ordinary case here rather than an error, so it retries quietly
* and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell
* you whether anything happened" is noise about a condition nobody can act on, and the session
* list already says what is waiting when they next look.
*/
private fun follow(settings: ServerSettings) {
while (!stopping) {
@@ -102,8 +102,8 @@ class NotificationService : Service() {
try {
connection.applyPinnedTls()
connection.connectTimeout = CONNECT_TIMEOUT_MS
// No read timeout, for the reason EventStream gives: between
// notifications there is nothing to read, possibly for hours.
// No read timeout, for the reason EventStream gives: between notifications there is
// nothing to read, possibly for hours.
connection.readTimeout = 0
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
connection.setRequestProperty("Accept", "text/event-stream")
@@ -134,28 +134,24 @@ class NotificationService : Service() {
*
* Keyed by session id rather than accumulating: two sessions wanting attention are two things
* to know about, but one session that finished and then asked a question is one thing -- the
* question. A stack of stale rows for the same conversation is how a notification drawer
* becomes something to clear rather than read.
* question. A stack of stale rows is how a drawer becomes something to clear rather than read.
*/
private fun show(notification: SessionNotification) {
// Nothing to tell somebody about the session they are reading. The transcript in front of
// them is already saying it, and a sound over the top of it would be this app announcing
// what the screen is showing.
// them is already saying it.
if (isOnScreen(notification.sessionId)) return
// The app is up: it says this itself, as a banner over whatever screen they are on. See
// [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an
// app that already showed you each one is a drawer nobody reads.
if (handOver(notification)) return
// The app is up, so it says this itself as a banner over whatever screen they are on --
// which interrupts, where the drawer's row records: a banner lasts seconds and reaches only
// somebody already looking. Both go up, and the banner having done the interrupting is what
// makes the row a silent one.
val banner = handOver(notification)
val manager = NotificationManagerCompat.from(this)
// Two different noes, and both are answers rather than faults: the runtime permission
// refused, and notifications switched off for the app in Android's own settings. Neither
// is reported anywhere -- the person said no, and saying it back to them through the
// channel they closed is not available anyway.
// refused, and notifications switched off for the app in Android's own settings.
//
// The permission only exists from Android 13. Asking an older version about it gets
// "denied" for a name it does not know, which read as the person having said no -- so
// every notification on Android 12 and below was silently dropped. Before 13 the
// switch in Android's own settings, checked below, is the whole of the answer.
// "denied" for a name it does not know, which read as the person having said no -- so every
// notification on Android 12 and below was silently dropped.
val allowed =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
@@ -179,6 +175,7 @@ class NotificationService : Service() {
.setAutoCancel(true)
.setWhen((notification.at * 1000).toLong())
.setShowWhen(true)
.setSilent(banner)
.build()
manager.notify(notification.sessionId, ALERT_ID, built)
}
@@ -187,9 +184,8 @@ class NotificationService : Service() {
* The type Android 14+ requires a foreground service to declare, and nothing before it.
*
* Named behind a version check rather than passed as a constant: the value is inlined at
* compile time and would be handed to platforms that have no concept of it, which is exactly
* the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do
* not apply.
* compile time and would be handed to platforms that have no concept of it, which is what
* lint's InlinedApi exists to catch.
*/
private fun foregroundType(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
@@ -226,11 +222,10 @@ class NotificationService : Service() {
/**
* Two channels, because they are two different things to be told.
*
* The alerts are what somebody turned this on for, so they get the default importance and
* whatever sound and heads-up display the person has chosen for the app. The ongoing one is
* the platform's tax for staying connected, so it takes the lowest importance that exists.
* Both are created before the service starts, since posting to a channel that does not
* exist is silently dropped.
* The alerts are what somebody turned this on for, so they get the default importance. The
* ongoing one is the platform's tax for staying connected, so it takes the lowest
* importance that exists. Both are created before the service starts, since posting to a
* channel that does not exist is silently dropped.
*/
private fun createChannels(context: Context) {
val manager = NotificationManagerCompat.from(context)
@@ -256,12 +251,10 @@ class NotificationService : Service() {
* The session somebody is looking at, or null when no screen is showing one.
*
* Process-wide state, which the rest of this app does without: Android constructs the
* service and the composition draws the screen, so the two have no common owner a value
* could be passed through. [showing] and [stoppedShowing] are the pair, both called from
* the one composable that shows a session. Clearing names the session rather than setting
* null outright, because moving from one session to another composes the new screen before
* the old one's coroutine is cancelled -- an unconditional clear would then throw away the
* new screen's claim and start notifying about what is on it.
* service and the composition draws the screen, so the two have no common owner. Clearing
* names the session rather than setting null outright, because moving from one session to
* another composes the new screen before the old one's coroutine is cancelled -- an
* unconditional clear would throw away the new screen's claim.
*/
@Volatile private var onScreen: String? = null
@@ -271,10 +264,10 @@ class NotificationService : Service() {
* The way a notification reaches the app instead of Android's drawer.
*
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
* [SessionAlerts] collects this exactly while it is on screen, so there is nothing that
* could be left saying the app is up after it has gone. `tryEmit` neither suspends nor
* blocks the thread reading the stream, and the buffer is there so a handful of sessions
* finishing together all land rather than the last one winning.
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
* nor blocks the thread reading the stream, and the buffer is there so a handful of
* sessions finishing together all land rather than the last one winning. Reaching the app
* does not stop the drawer's row; it makes it a silent one.
*/
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
@@ -284,12 +277,15 @@ class NotificationService : Service() {
private fun handOver(notification: SessionNotification) =
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) {
onScreen = sessionId
// Whatever was posted about it before is about to be read, so it has nothing left
// to say -- and a row in the drawer for the conversation on screen is the same
// duplication this whole rule is about.
// Whatever was posted about it before is about to be read, so it has nothing left to
// say.
NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID)
}
@@ -311,8 +307,8 @@ class NotificationService : Service() {
* The intent that opens one session, and the id it carries back out.
*
* The two halves are written together so neither can be changed without the other, and the scheme
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look at
* when an intent arrives rather than two.
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look
* at.
*
* The id rides in the intent's **data** rather than in an extra, which is not a style choice:
* PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring
@@ -345,9 +341,8 @@ data class SessionNotification(
* What a notification asks of the reader, in the words they see.
*
* What they have to do, not what the session did: "awaitingInput" is the wire's word and says
* nothing to somebody reading a lock screen. One function because the same fact is now shown in two
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The
* banner colours the line as well, which is its own decision and stays with the drawing.
* nothing to somebody reading a lock screen. One function because the same fact is shown in two
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift.
*/
fun attentionLine(kind: String): String =
when (kind) {
@@ -31,13 +31,12 @@ import androidx.compose.ui.unit.dp
*
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
* transcript that puts it in their voice is making a claim about who asked for the work that
* follows -- which is exactly the question a peer message is usually the answer to.
* follows.
*
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for the measurements that
* bought; what matters here is that the pieces have to add up to the card that was there before, so
* the fill, the corner radius and the padding all live in [peerSurface] rather than being written
* out at each piece.
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought;
* what matters here is that the pieces have to add up to the card that was there before, so the
* fill, the corner radius and the padding all live in [peerSurface].
*/
@Composable
fun PeerHeadRow(
@@ -91,9 +90,9 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
// Without this the card closes everywhere except on the text, which is most of it.
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
// The gap the card's own column used to provide between its heading and its prose,
// and between one block and the next -- inside the piece, so the card's fill runs
// through it.
// The gap the card's own column used to provide between its heading and its prose, and
// between one block and the next -- inside the piece, so the card's fill runs through
// it.
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
}
}
@@ -102,16 +101,14 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
/**
* One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it.
*
* A filled Material card is elevation zero ([CardDefaults] takes it from `FilledCardTokens`, which
* is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason a
* card can be cut up at all. Each piece paints the caller's container colour the way a
* [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message,
* so the pieces abut into one continuous card. Shared by the two rows that are cut this way -- an
* opened peer message and a long user message -- because two copies of the corner logic is how one
* of them grows a seam.
* A filled Material card is elevation zero, so there is no shadow that a seam would show through --
* which is the whole reason a card can be cut up at all. Each piece paints the caller's container
* colour and rounds only the corners at the ends of the message, so the pieces abut into one
* continuous card. Shared by the two rows cut this way -- an opened peer message and a long user
* message -- because two copies of the corner logic is how one of them grows a seam.
*
* The padding is the other half of it: 12dp all round was the card's own, so the top piece keeps
* the top of it, the bottom piece the bottom, and the middle pieces neither.
* The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top
* of it, the bottom piece the bottom, and the middle pieces neither.
*/
@Composable
fun Modifier.cardPiece(
@@ -34,13 +34,10 @@ import androidx.compose.ui.unit.sp
* What is about to be sent, directly above the box it will be sent from.
*
* The count on the "+" button was the whole of what said an image was attached, so the only way to
* find out *which* image was to send it. A control belongs with the thing it acts on, and what
* these are attached to is the message being typed -- which is why they sit here rather than
* anywhere else on the screen.
* find out *which* image was to send it. A control belongs with the thing it acts on.
*
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
* in it, so four attachments look like four of the same thing rather than four smaller ones. A file
* is a tile of the same height carrying its name, since a name is all there is to show of it.
* in it, so four attachments look like four of the same thing rather than four smaller ones.
*/
@Composable
fun PendingAttachments(
@@ -67,8 +64,7 @@ fun PendingAttachments(
*
* Removal is here because there is nowhere else it could be: an image picked by mistake could
* otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip -- and
* the label is what says so, since nothing about the picture does.
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip.
*/
@Composable
private fun PendingThumbnail(
@@ -84,8 +80,7 @@ private fun PendingThumbnail(
.clip(shape)
// An outline as well as a fill. Most of what gets attached here is a screenshot of a
// dark app, and cropped to a square its middle is often near-black -- against this
// background the tile then had no edge at all, and the only thing saying an image was
// attached was the cross drawn on top of nothing.
// background the tile then had no edge at all.
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
// Behind the picture as well as under a missing one, so the tile is a tile before
// anything has arrived to fill it.
@@ -105,9 +100,9 @@ private fun PendingThumbnail(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// A spinner, as the transcript's images have: one appearance for "a picture
// is on its way", learned once. An ellipsis had to be read as a spinner that
// was not moving.
// A spinner, as the transcript's images have: one appearance for "a picture is
// on its way", learned once. An ellipsis had to be read as a spinner not
// moving.
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
else ->
@@ -118,14 +113,12 @@ private fun PendingThumbnail(
modifier = Modifier.size(THUMBNAIL),
)
}
// The whole square removes it, and this only says so. A cross small enough to sit in
// the corner of a 64dp thumbnail is smaller than a fingertip, so making it the target
// would be a control drawn at a size nobody can hit.
// The whole square removes it, and this only says so. A cross small enough to sit in the
// corner of a 64dp thumbnail is smaller than a fingertip.
//
// The disc is sized here and the mark centred inside it, rather than the glyph being
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box
// to the corner hung the visible mark over the edge and put its backing somewhere the
// eye reads as a second, misplaced square.
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to
// the corner hung the visible mark over the edge.
Box(
Modifier.align(Alignment.TopEnd)
.padding(2.dp)
@@ -0,0 +1,121 @@
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(),
)
}
}
}
@@ -3,15 +3,13 @@ package com.example.aiapp
import com.example.wgapplink.PinnedTls
import java.net.HttpURLConnection
// PINNED_CA_PEM is generated at build time from the CA on the machine doing
// the build -- see the generatePinnedCert task in build.gradle.kts. It is
// deliberately not a checked-in constant: the private key that signs against
// it must never be anywhere this repo is, and an APK should pin whatever CA
// the backend it was built for actually serves.
// PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the
// generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the
// private key that signs against it must never be anywhere this repo is, and an APK should pin
// whatever CA the backend it was built for actually serves.
//
// The pinning itself lives in wg-app-link, since dev-updater needs exactly
// the same thing. What stays here is the one product-specific fact -- which
// certificate this app pins.
// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What
// stays here is which certificate this app pins.
private val pinned = PinnedTls(PINNED_CA_PEM)
/** Every request this app makes goes through this -- there is no unpinned path. */
@@ -0,0 +1,211 @@
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") }
}
},
)
}
@@ -0,0 +1,111 @@
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.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,
/**
* How a choice is drawn here, which is the screen's to decide rather than the setting's: chips
* on a form somebody is filling in, a picker row in a list of settings. A provider's choices
* have to look like the choices beside them, whichever screen that is.
*/
choices: ChoiceStyle,
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()
val selected = values[spec.key] ?: default
val pick = { chosen: String -> set(if (chosen == default) "" else chosen) }
when (choices) {
ChoiceStyle.Chips ->
ChipGroup(
label = spec.label,
options = spec.options,
selected = selected,
onSelect = pick,
)
ChoiceStyle.Picker -> PickerRow(spec.label, selected, spec.options, pick)
}
}
else ->
LabelledField(
label = spec.label,
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))
}
}
}
/** Which control a [ParamKind.Choice] gets -- see [ProviderParamFields]'s `choices`. */
enum class ChoiceStyle {
Chips,
Picker,
}
/**
* 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
@@ -0,0 +1,509 @@
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 },
)
}
}
@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) }
// Whether saving costs anything worth asking about: something has to be in memory, and at
// least one of these settings has to be one it read on the way in.
val reloads =
(model.status == "loaded" || model.status == "sleeping") && specs.any { it.restart }
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 },
// Chips: this is a form of its own rather than a row in a list of settings.
choices = ChoiceStyle.Chips,
)
}
},
confirmButton = {
TextButton(onClick = { if (reloads) 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,10 +1,12 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -16,22 +18,31 @@ import androidx.compose.ui.unit.dp
*
* A composable rather than a modifier repeated at each site, because the inset is part of it --
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
* copies of "clip, fill, pad" drift apart the first time one of them is adjusted.
* copies of "clip, fill, pad" drift apart the first time one is adjusted.
*
* The colour is [rawSurface], which is also what a code block inside a reply is given; that is the
* point of having one name for it. Markdown's blocks are painted by the renderer rather than by
* this, since it draws its own, but they are the same colour on purpose.
* **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.
*/
@Composable
fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier
.fillMaxWidth()
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a
// rounded rectangle drawn at the same radius as the rounded rectangle behind it reads
// as a misprint rather than as nesting.
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded
// rectangle drawn at the same radius as the one behind it reads as a misprint.
.clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface)
// 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),
content = content,
)
@@ -0,0 +1,284 @@
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
@@ -0,0 +1,89 @@
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 }
}
@@ -4,17 +4,16 @@ import java.time.Duration
import java.time.OffsetDateTime
// How long is left in a usage window. Shared by the session bar and the usage screen: the
// arithmetic is the same in both and only the sentence around it differs, so everything here
// returns the span or the state on its own and leaves the wording to the caller.
// arithmetic is the same in both, so everything here returns the span or the state on its own and
// leaves the wording to the caller.
/**
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
*
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
* figure on a minute it has already spent -- so the reader watching the number decide whether to
* start something was consistently told less headroom than they had. One rule, so the session bar
* and the usage dialog cannot round a shared measurement two different ways.
* figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot
* round a shared measurement two different ways.
*/
fun formatSpan(until: Duration): String {
val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1)
@@ -31,14 +30,12 @@ fun formatSpan(until: Duration): String {
* Three answers rather than a nullable duration, because two of them shared `null` and they are not
* the same thing at all. A window the server sent no reset time for is one that is **not running**:
* the five-hour window is anchored to the block it started in, so between sessions there is nothing
* counting down and the API says so by omitting the field -- measured against a live response on
* 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work
* resumed. A timestamp that did arrive and could not be read is the genuinely unknown case, and it
* is the only one worth those words.
* counting down and the API says so by omitting the field. A timestamp that did arrive and could
* not be read is the genuinely unknown case.
*
* Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on
* the one row somebody reads before starting something big -- and the usage dialog, looking at the
* same field, quietly drew nothing. Two rules for one missing value; this is the rule.
* same field, quietly drew nothing.
*/
sealed class WindowEnd {
/** No reset time was sent, so nothing is running in this window. Not a failure to find out. */
@@ -0,0 +1,35 @@
package com.example.aiapp
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
/**
* What a setting costs, asked before it is written.
*
* Every setting in this app whose consequence is worth saying says it here rather than in a
* paragraph beside the control: a sentence under a switch is read after the decision if it is read
* at all, and a form of a dozen settings each carrying its own explanation is mostly explanation. A
* modal interrupts at the moment the consequence becomes real, and it is also a way out.
*
* What they have in common, and why it is one dialog rather than four: each of them ends something
* that is running a process, a loaded model, a server and says what starting it again costs.
*/
@Composable
fun RestartDialog(
title: String,
text: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
/** The word on the button, which is the action rather than a bare "OK". */
confirm: String = "Save",
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(text) },
confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -10,24 +10,21 @@ private const val ANCHORS = "session-scroll"
*
* Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by
* the row key the list draws with. An index means nothing across a reopen, since the transcript is
* fetched newest-first and a session that has said anything since has renumbered every position.
* The row key looks stable and is not: a tool row is named after its run, `joinPages` gives a run
* the name of its newest half, and the newest half is whatever the newest page happened to start
* with -- so an active session renames its tool runs every time it is reopened, and an anchor
* naming one is never found. A seq is the server's own numbering, assigned once and never moved.
* fetched newest-first. The row key looks stable and is not: a tool row is named after its run,
* `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest
* page started with -- so an active session renames its tool runs every time it is reopened. A seq
* is the server's own numbering, assigned once and never moved.
*
* [unit] is which unit of the row the viewport started at -- see [TranscriptUnit.ordinal] -- and
* [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone
* is not a place: a reply is one seq and can be forty blocks long, and a reader stopped halfway
* down it is put back at that block, not at the reply.
* [unit] is which unit of the row the viewport started at and [offset] how far that unit was
* scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can
* be forty blocks long.
*/
data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0)
/**
* On this device rather than on the backend, which is where this app otherwise keeps state so every
* device sees it. Scroll position is the same exception a draft is: it is where the phone in
* somebody's hand is pointed, and having one device jump because another was scrolled would be a
* surprise rather than a convenience.
* somebody's hand is pointed.
*/
fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val stored =
@@ -36,8 +33,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val fields = stored.split(':')
val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null
val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null
// Positions saved before the unit was recorded name the row's oldest unit, which is the
// closest older place -- the same choice [unitIndexFor] makes when a unit is gone.
// Positions saved before the unit was recorded name the row's oldest unit, which is the closest
// older place -- the same choice [unitIndexFor] makes when a unit is gone.
return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0)
}
@@ -45,9 +42,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
* Records where [sessionId] is being read, or forgets it when [anchor] is null.
*
* The path out is reading to the newest end, which is what the caller passes null for: a session
* left at the bottom has nothing to restore and should open at the bottom, which is also the cheap
* case. A session *deleted* while it held an anchor leaves its key behind, for the reason and at
* the cost `Drafts.kt` describes.
* left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its
* key behind, for the reason and at the cost `Drafts.kt` describes.
*/
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
@@ -14,10 +14,9 @@ typealias ServerSettings = com.example.wgapplink.ServerSettings
/**
* This app's enrollment, which is the whole of what is product-specific about it.
*
* Both values are load-bearing and neither may be changed casually. The scheme is what routes a
* scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the
* token is already sealed under on every enrolled phone -- changing it would leave those phones
* reading as not enrolled, with no error to explain why.
* Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev
* Updater, and the key alias names the Android Keystore key the token is already sealed under on
* every enrolled phone -- changing it would leave those phones reading as not enrolled.
*/
private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key")
@@ -31,18 +31,16 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
/**
* A session wanting attention, said over the app rather than through Android's drawer.
* A session wanting attention, said over the app as well as in Android's drawer.
*
* Two places can carry the same fact and only one of them is right at a time. A row in the shade is
* for somebody looking at something else: it makes a sound, it waits however long it has to, and
* acting on it means leaving whatever they were doing. Somebody with this app open needs none of
* that -- they are already here, and what a tap on the notification would have done is what a tap
* on this does. So while these are on screen the stream is delivered here instead, which is
* arranged by the collection below and nothing else; see `NotificationService.forTheScreen`.
* Two places carry the same fact and they are doing different jobs: a row in the shade waits
* however long it has to, which makes it the record, and a banner is read now or not at all, which
* makes it the interruption. So somebody with the app open gets both -- this, and a silent row
* behind it that is still there when they go looking and goes by itself when they open the session.
* Whether the app is open at all is this collection and nothing else.
*
* A banner can go three ways, and each is somebody deciding something different: tapped, which
* opens the session; pushed off either side; or left alone, in which case it goes by itself when
* the bar across its foot runs out.
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
*/
@Composable
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
@@ -58,28 +56,26 @@ fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Mod
arrivals++
val alert = SessionAlert(notification, arrivals)
// One banner per session, replacing that session's own -- the same rule the
// drawer follows, and for the same reason: a session that finished and then
// asked a question is one thing to know about, the question. It keeps its
// place in the queue rather than moving to the end, because the reader may
// already be reaching for it.
// drawer follows: a session that finished and then asked a question is one
// thing to know about, the question. It keeps its place in the queue rather
// than moving to the end, because the reader may already be reaching for it.
val already = queue.indexOfFirst {
it.notification.sessionId == notification.sessionId
}
if (already >= 0) queue[already] = alert else queue.add(alert)
}
} finally {
// Leaving the app hands the job back to the drawer, so nothing arriving while it
// is away is lost. What would be lost is the truth of what is already up: these
// say a session wants somebody *now*, and one still sitting here on a return
// several minutes later is a claim nobody checked. Frozen, too -- Compose stops
// the clock with the window, so the timer that was going to retire it has been
// standing still the whole time.
// Leaving the app hands the job back to the drawer, so nothing arriving while it is
// away is lost. What would be lost is the truth of what is already up: these say a
// session wants somebody *now*, and one still sitting here on a return several
// minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock
// with the window.
queue.clear()
}
}
}
// Oldest at the top, so a new one appears below the ones already being read instead of
// shoving them down the screen mid-reach.
// Oldest at the top, so a new one appears below the ones already being read instead of shoving
// them down the screen mid-reach.
Column(modifier.fillMaxWidth().padding(8.dp)) {
queue.forEach { alert ->
key(alert.arrival) {
@@ -103,9 +99,7 @@ private data class SessionAlert(val notification: SessionNotification, val arriv
* One banner: what wants attention, and how long this has left to say so.
*
* The bar and the going away are one value rather than a bar beside a timer, because two of them
* would be two accounts of the same countdown and only one can be the one that fires. What is drawn
* is therefore the thing that decides, which is the only arrangement where a bar that has emptied
* cannot be sitting under a banner that is still there.
* would be two accounts of the same countdown and only one can be the one that fires.
*/
@Composable
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
@@ -135,9 +129,7 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
),
// Outlined, because the step it needs to make is not one this palette can make with a
// tint: the card under a banner on the session list is the same surface, so a banner
// relying on colour alone reads as one more row that happens to be in the way. The
// border is the one cue, and the elevation beside it is the platform's shadow rather
// than a second tint -- Material draws no tonal overlay over a container stated here.
// relying on colour alone reads as one more row in the way. The border is the one cue.
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
) {
@@ -145,8 +137,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
Text(
alert.notification.title,
style = MaterialTheme.typography.titleSmall,
// One line, cut at the tail: a session is identified by the start of its
// name, and a banner that grew with the name would move the one below it.
// One line, cut at the tail: a session is identified by the start of its name,
// and a banner that grew with the name would move the one below it.
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -163,9 +155,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
LinearProgressIndicator(
progress = { life.value },
// Blue because it is reporting how much of something is left rather than passing
// judgement on it -- the reason `progressColor` exists. Stated beside the track,
// which is the card's own colour so that the spent part reads as empty rather
// than as a second bar.
// judgement on it. Stated beside the track, which is the card's own colour so that
// the spent part reads as empty rather than as a second bar.
color = progressColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
drawStopIndicator = {},
@@ -180,7 +171,6 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
* How long a banner stays if nobody touches it.
*
* Long enough to read a session name and a line, short enough that a stack of them clears itself
* while somebody is still on the screen that produced them. The bar makes the number visible, so
* this is a duration the reader can watch rather than one they have to learn.
* while somebody is still on the screen that produced them. The bar makes the number visible.
*/
private const val ALERT_LIFE_MS = 6_000
@@ -5,24 +5,32 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
@@ -30,12 +38,20 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.window.Dialog
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.withContext
@@ -49,10 +65,9 @@ data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean)
/**
* Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling
* does not refetch.
*
* Shared by the transcript's images and the composer's pending attachments, because the fetch, the
* decode and the two-state answer are one block of logic that had been written twice.
* does not refetch. Shared by the transcript's images and the composer's pending attachments,
* because the fetch, the decode and the two-state answer are one block of logic that had been
* written twice.
*/
@Composable
fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap {
@@ -75,15 +90,12 @@ fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: Stri
* An image in the transcript: a fixed-height thumbnail that opens full screen.
*
* The height is decided before the bytes arrive and never changes. An image row that grew when it
* finished loading pushed everything below it, so a transcript being read scrolled itself while
* somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport
* moved the text under the reader's eyes. Reserving the final height makes loading invisible, which
* is what it should be.
* finished loading pushed everything below it, so a transcript being read scrolled itself -- and in
* a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
*
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
* than as a page of its own. Full size is one tap away -- but the full-size view itself is not
* here. [onOpen] hands the ref to the screen, which draws [SessionImageViewer] outside the list;
* see that function for the reason.
* than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
* screen, which draws [SessionImageViewer] outside the list.
*/
@Composable
fun SessionImage(
@@ -97,9 +109,8 @@ fun SessionImage(
val heightPx = with(LocalDensity.current) { height.roundToPx() }
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
when (val image = bitmap) {
// Two states, not one: an image still arriving and an image that will never arrive
// look nothing alike to a reader who can do something about the second. So one gets a
// spinner in the space the picture is about to fill, and the other gets words.
// Two states, not one: an image still arriving and an image that will never arrive look
// nothing alike to a reader who can do something about the second.
null ->
if (failed) {
Text(
@@ -130,15 +141,12 @@ fun SessionImage(
* `Read` on its own is a row of one call, and the moment the next call arrives the two become a
* group -- a different composable in a different part of the tree, so everything the old subtree
* remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the
* transcript because the session made another tool call. The same happens to a row regrouped by a
* page of history landing.
* transcript because the session made another tool call.
*
* Held by the screen, none of that reaches it: what is open is a property of the screen, not of
* whichever row happened to draw the thumbnail.
* Held by the screen, none of that reaches it: what is open is a property of the screen.
*
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
* through. Paid deliberately rather than plumbed around: it is one request for a picture somebody
* asked to see, and the loading and unavailable states below are the same two the thumbnail draws.
* through. Paid deliberately: it is one request for a picture somebody asked to see.
*/
@Composable
fun SessionImageViewer(
@@ -148,18 +156,29 @@ fun SessionImageViewer(
onClose: () -> Unit,
) {
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(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
properties =
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) {
ViewerSystemBars(hiddenBars)
Box(
Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
Modifier.fillMaxSize()
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClose,
),
contentAlignment = Alignment.Center,
) {
when (val image = bitmap) {
// Two states, not one, exactly as the thumbnail has them: still coming, and never
// coming. Stated in white because this box paints its own black behind them and a
// theme colour would be picked against a surface that is not there.
// Two states, not one, exactly as the thumbnail has them. Stated in white because
// this box paints its own black behind them and a theme colour would be picked
// against a surface that is not there.
null ->
if (failed) {
Text(
@@ -170,11 +189,49 @@ fun SessionImageViewer(
} else {
// The whole dialog is the area this picture is about to fill, so the
// spinner sits in the middle of it. White for the same reason the words
// beside it are: this box paints its own black, and a theme colour would
// be chosen against a surface that is not there.
// beside it are.
CircularProgressIndicator(color = Color.White)
}
else -> ZoomableImage(image)
else -> {
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%")
}
}
}
}
}
@@ -185,11 +242,10 @@ fun SessionImageViewer(
*
* A square of the row's own height rather than the full width of the transcript: the height is what
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
* placeholder would promise a picture wider than most of them turn out to be. Square is the closest
* thing to "the size of it" that can be drawn before knowing.
* placeholder would promise a picture wider than most turn out to be.
*
* Tinted, so the reader can see that something is being kept for a picture. That is also what
* distinguishes it from the failure beside it, which is words on the ordinary surface.
* Tinted, so the reader can see that something is being kept for a picture -- which is also what
* distinguishes it from the failure beside it, words on the ordinary surface.
*/
@Composable
private fun LoadingImage(height: Dp) {
@@ -210,8 +266,8 @@ private val LOADING_SPINNER = 24.dp
* Four lines of the body style the transcript is set in.
*
* Measured from the type rather than written as a dp, so it stays four lines when the text size
* changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded
* height would be wrong.
* changes -- including when the reader has scaled fonts up, which is when a hardcoded height is
* wrong.
*/
@Composable
private fun thumbnailHeight(): Dp {
@@ -226,8 +282,7 @@ private fun thumbnailHeight(): Dp {
* Nearest neighbour when the image is being enlarged, smooth when it is being shrunk.
*
* A small image blown up with interpolation turns into a blur that hides what it is -- the same
* image with hard pixel edges stays readable. Shrinking wants the opposite, so this is a decision
* per image rather than a preference set once.
* image with hard pixel edges stays readable. Shrinking wants the opposite.
*/
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High
@@ -236,34 +291,72 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality
* 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
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
* visible, which is the thing a reader wants first; zoom is theirs from there.
* gesture returns to the transcript instead of leaving the app. It opens fitted, with the whole
* image visible without enlarging a smaller one; the 100% control changes to one bitmap pixel per
* screen pixel and recenters it.
*/
@Composable
private fun ZoomableImage(image: ImageBitmap) {
private fun ZoomableImage(
image: ImageBitmap,
nativeSizeRequest: Int,
onViewportChanged: (IntSize) -> Unit,
onBarsChanged: (ViewerBars) -> Unit,
barInsets: ViewerBarInsets,
viewport: IntSize,
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX 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(
bitmap = image,
contentDescription = "Attached image",
contentScale = ContentScale.Fit,
contentScale = ContentScale.Inside,
// Zoomed in, the reader is looking at pixels on purpose.
filterQuality = FilterQuality.None,
modifier =
Modifier.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
// Floor of 1 so the image cannot be pinched smaller than fitted, which is
// already the whole of it; a ceiling so it cannot be lost off-screen.
scale = (scale * zoom).coerceIn(1f, 8f)
if (scale > 1f) {
offsetX += pan.x
offsetY += pan.y
.onSizeChanged(onViewportChanged)
.pointerInput(nativeScale) {
detectTransformGestures { centroid, pan, zoom, _ ->
val oldScale = scale
val maximumScale = maxOf(8f, nativeScale)
val newScale = (oldScale * zoom).coerceIn(1f, maximumScale)
if (newScale > 1f) {
val offset =
zoomOffset(
Offset(offsetX, offsetY),
centroid,
pan,
oldScale,
newScale,
Offset(size.width / 2f, size.height / 2f),
)
offsetX = offset.x
offsetY = offset.y
} else {
offsetX = 0f
offsetY = 0f
}
scale = newScale
}
}
.graphicsLayer {
@@ -274,3 +367,104 @@ private fun ZoomableImage(image: ImageBitmap) {
},
)
}
/** 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,9 +1,11 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -12,11 +14,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -29,14 +35,30 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
* The sessions tab: every session, in the order the reader has put them in.
*
* 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.
* What stays here is the button that adds a session, because that acts on this list and nothing
@@ -48,67 +70,186 @@ fun SessionListScreen(
reloadToken: Int,
onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit,
/** A session this list has just deleted, for whoever is showing it elsewhere. */
onDeleted: (String) -> Unit = {},
) {
val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Failures that belong to one session rather than to the list, keyed by
// its id and shown on its own card. The two scopes are decided by
// whether the server answered: it answered and refused, so this says
// nothing about the other rows, where a server that has stopped
// answering leaves every row stale and is `listState`'s to report.
// Which 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
// own card. The two scopes are decided by whether the server answered: it answered and refused,
// so this says nothing about the other rows.
//
// Cleared on the next successful load below -- an entry outlives its
// session otherwise, and would reappear against whatever the phone
// fetched next.
// Cleared on the next successful load below -- an entry outlives its session otherwise.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which sessions have a delete in flight. A set of ids rather than a flag on the row,
// because the rows are rebuilt from whatever the server last said and this belongs to the
// request rather than to the session.
// 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
// the rows are rebuilt from whatever the server last said and this belongs to the request.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
// This phone's copies of these sessions' transcripts, pruned from here because this is where
// a session stops existing. See TranscriptCache.
// This phone's copies of these sessions' transcripts, pruned from here because this is where a
// session stops existing. See TranscriptCache.
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
fun refresh() {
listState = LoadState.Loading
// The rows stay while the answer is on its way, with the bar below saying one is: this
// 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 {
listState =
try {
val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
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
// else -- from another device, or at the backend. This list is the only place
// that ever learns the full set, and what the residue costs here is megabytes
// rather than a draft's few bytes. On the answer rather than in `finally`: a
// list that failed to arrive says nothing about which sessions exist.
withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
}
// else. This list is the only place that ever learns the full set. On the
// answer rather than in `finally`: a list that failed to arrive says nothing
// about which sessions exist.
withContext(Dispatchers.IO) { transcriptCache.retainOnly(alive) }
loaded
} catch (e: ApiException) {
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() }
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()) {
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) {
is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is
// already a whole sentence naming the address and what to
// check, so a prefix here read "Couldn't reach the server:
// Couldn't reach the server at ...". It was also a guess --
// a delete that the server itself refused had reached it
// fine.
// The message as Api.kt wrote it, with nothing added: it is already a whole
// sentence naming the address and what to check, so a prefix here read "Couldn't
// reach the server: Couldn't reach the server at ...".
is LoadState.Error ->
Text(
state.message,
@@ -122,21 +263,32 @@ fun SessionListScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Awaiting-answer first (the point of the screen), then
// most recently active.
val ordered =
state.value.sortedWith(
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
.thenByDescending { it.lastActivity }
)
LazyColumn {
uniqueItems(ordered, key = { it.id }) { session ->
LazyColumn(
state = rows,
contentPadding =
PaddingValues(bottom = covered + buttonHeight + BUTTON_RING * 2),
) {
uniqueItems(state.value, key = { it.id }) { session ->
SessionCard(
session = session,
error = deleteErrors[session.id],
deleting = session.id in deleting,
onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session },
picked = session.id in selected,
// The handle is a selection-mode control, so it is absent rather
// 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))
}
@@ -145,73 +297,114 @@ 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(
onClick = onSpawn,
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
modifier =
Modifier.align(Alignment.BottomEnd)
.padding(end = BUTTON_RING, bottom = BUTTON_RING + covered)
.onSizeChanged { buttonHeight = with(density) { it.height.toDp() } },
) {
Text("+", style = MaterialTheme.typography.headlineMedium)
}
}
confirmingDelete?.let { session ->
// Reset per session, so a toggle turned on for one conversation is not still on for the
// next one somebody opens this dialog for. Off to begin with: see [deleteSession].
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
val targets = confirmingDelete
if (targets.isNotEmpty()) {
// Reset per selection, so a toggle turned on for one set of conversations is not still
// on for the next. Off to begin with: see [deleteSession].
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(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete \"${session.title}\"?") },
onDismissRequest = { confirmingDelete = emptyList() },
title = {
Text(
if (targets.size == 1) "Delete \"${targets.first().title}\"?"
else "Delete ${targets.size} sessions?"
)
},
text = {
// Two different acts behind one button, so it says which one this is. What
// separates them is whether the *driver* keeps its own record of the
// conversation -- the Claude Code CLI does, under ~/.claude/projects, whether
// this app spawned the session or imported it; echo and llama.cpp do not, and
// for those the app's transcript is the only copy there is.
// conversation
// -- the coding CLIs do, whether this app spawned the session or imported it;
// echo and llama.cpp do not.
//
// This used to branch on `imported`, above a comment asserting that "a session
// started here has no copy anywhere". That was simply false for every
// claude-cli session this app spawned, and the two warnings disagreed about
// sessions that were equally recoverable. Getting it wrong in that direction
// is the expensive one: "this can't be undone", said of something that can,
// spends the credibility the sentence needs on the sessions where it is true.
// started here has no copy anywhere". That was false for every coding-CLI session
// this app spawned, and getting it wrong in that direction is the expensive one:
// "this can't be undone", said of something that can, spends the credibility that
// sentence needs.
//
// Neither branch promises a restore. The recoverable one says what is known --
// the driver keeps its own record -- rather than that the file is still there,
// which nothing here checked; and it names what goes either way, because this
// app's transcript holds images, peer messages and commands that the CLI's own
// record never had.
// Neither branch promises a restore. The recoverable one says what is known,
// that the driver keeps its own record, rather than that the file is still there,
// and it names what goes either way, because this app's transcript holds images,
// peer messages and commands the CLI's own record never had.
//
// 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 {
Text(
when {
!session.keepsOwnTranscript ->
owned.isEmpty() ->
"Kills the process and deletes the conversation. Nothing else " +
"keeps a copy, so this can't be undone."
// The sentence below is the one the toggle makes false, which is why
// it is written twice rather than appended to: leaving "should still
// be there to import again" on screen beside a switch that removes it
// is the reassurance being read at the moment it stops being true.
// The sentence below is the one the toggle makes false, which is
// why it is written twice rather than appended to: "should still be
// there to import again", left on screen beside a switch that removes
// it, is the reassurance being read as it stops being true.
alsoDeleteForeign ->
"Kills the process and deletes both copies of the conversation: " +
"this app's, and Claude Code's own transcript on the " +
"this app's, and $transcriptOwner's own transcript on the " +
"machine. Nothing keeps another, so this can't be undone."
else ->
"Stops the process and deletes this app's copy of the " +
"conversation, including any images, peer messages and " +
"commands recorded only here. Claude Code keeps its own " +
"transcript on the machine, so the conversation itself " +
"should still be there to import again."
"commands recorded only here. $transcriptOwner keeps its own " +
"transcript on the machine" +
(if (owned.size < targets.size) " for some of these" else "") +
", so the conversation itself should still be there to " +
"import again."
}
)
// Only where there is a second copy to decide about. Absent rather than
// disabled, because this is not a capability being withheld: for echo and
// llama.cpp there is no other transcript, and a switch offering to delete
// one would be asking about something that does not exist.
if (session.keepsOwnTranscript) {
// llama.cpp there is no other transcript, and a switch offering to delete one
// would be asking about something that does not exist.
if (owned.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
// Its own row rather than beside the paragraph: a switch is taller than
// a line of text and re-centres whatever shares a row with it.
// Its own row rather than beside the paragraph: a switch is taller
// than a line of text and re-centres whatever shares a row with it.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Delete Claude Code's transcript too",
"Delete $transcriptOwner's transcript too",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
@@ -227,54 +420,64 @@ fun SessionListScreen(
confirmButton = {
TextButton(
onClick = {
confirmingDelete = null
// Marked here rather than after the request returns: the row has to say
// something is happening to it from the moment it is asked for, which
// is the whole of what this state is for.
deleting = deleting + session.id
deleteErrors = deleteErrors - session.id
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it.
transcriptCache.session(session.id).purge()
}
// Only this row, and only what changed. Refetching the list
// instead put every other session back through loading and
// handed the reader an empty screen -- to report on something
// that was never in doubt.
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
}
}
confirmingDelete = emptyList()
deleteChosen(targets, alsoDeleteForeign)
}
) {
// Coloured by consequence: this takes something away, and does so wherever
// it appears -- the same rule the import screen's Delete follows.
// Coloured by consequence: this takes something away, and does so wherever it
// appears -- the same rule the import screen's Delete follows.
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
TextButton(onClick = { confirmingDelete = emptyList() }) { 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)
@Composable
private fun SessionCard(
@@ -286,67 +489,121 @@ private fun SessionCard(
*
* Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its
* way out without claiming it has gone: a row removed the moment Delete is pressed is a promise
* about a request that has not been answered yet, and putting it back when the server refuses
* is worse than never having taken it away.
* about a request that has not been answered yet.
*/
deleting: Boolean,
onOpen: () -> Unit,
/** Whether this row is one of the selection the bottom bar acts on. */
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,
) {
val held = reorder?.held == session.id
BusyItem(label = if (deleting) "deleting" else null) {
Card(
// Off while the delete is in flight: a card that still opens a session it is
// deleting is a race the reader can start by tapping. On the card rather than in
// [BusyItem], which leaves gestures alone so the list still scrolls.
Modifier.fillMaxWidth()
.combinedClickable(
enabled = !deleting,
onClick = onOpen,
onLongClick = onLongPress,
)
colors =
if (picked)
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
else CardDefaults.cardColors(),
// Lifted while it is in hand, which is the one cue that says this row is being carried
// rather than sitting where it belongs.
elevation = CardDefaults.cardElevation(defaultElevation = if (held) 8.dp else 0.dp),
modifier =
Modifier.fillMaxWidth()
// 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 },
) {
Column(Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
// Everything but the handle, which is what makes the two gestures separate
// rather than competing: a press that lands on the handle never reaches this,
// so holding it cannot select the row it is about to move. The card had the
// click while the handle was the only thing inside it that did not want one,
// and a hold on the handle then both selected the row and ate the drag.
//
// Off while the delete is in flight: a card that still opens a session it is
// deleting is a race the reader can start by tapping. Here rather than in
// [BusyItem], which leaves gestures alone so the list still scrolls.
Modifier.weight(1f)
.combinedClickable(
enabled = !deleting,
onClick = onClick,
onLongClick = onLongPress,
)
.padding(CARD_PADDING)
) {
Text(
session.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
StatusText(session.status)
}
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.setupName,
session.provider,
session.model?.let { modelLabel(it) },
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
session.title,
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),
)
.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,
)
}
}
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,
)
}
}
error?.let {
Spacer(Modifier.height(8.dp))
// The server's own words, unprefixed, the way every other
// failure in this app 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
// open for it outside selection mode: the row is then the row it always was.
if (reorder != null) {
ReorderHandle(
reorder,
session.id,
// Dimmed with the rest of the row while something is happening to it, since
// a row on its way out is not one to rearrange -- see [BusyItem], whose
// appearance this matches rather than repeating its dimming rule.
// The mark lines up with the text on the other side of the card,
// which means taking the square it is centred in off the gap: see
// [HANDLE_MARGIN].
Modifier.alpha(if (deleting) 0.4f else 1f)
.padding(end = CARD_PADDING - HANDLE_MARGIN),
)
}
}
@@ -356,22 +613,12 @@ private fun SessionCard(
@Composable
fun StatusText(status: String) {
val (label, color) =
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
}
val label = sessionStatusWord(status)
val color = sessionStatusColour(status)
Row(verticalAlignment = Alignment.CenterVertically) {
if (sessionWorking(status)) {
// The same colour as the word beside it: the two are one signal, and a spinner in
// the theme's accent says the state is something other than what the label says.
// The same colour as the word beside it: the two are one signal, and a spinner in the
// theme's accent says the state is something other than what the label says.
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
File diff suppressed because it is too large. Load diff
@@ -1,337 +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.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" -- and a control belongs
* with the thing it acts on.
*
* Captions are for what a control costs rather than for what it is. Each control is a labelled noun
* with a switch or a field beside it, and a paragraph under every one of them made the dialog
* longer than the conversation it covers -- so Notifications has none, while Move and Reload do,
* because what those two take away is not visible from here. Failures get their words for the same
* reason: they are what the reader cannot work out by looking.
*/
@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 -- see `copyRenderReport` there.
*/
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 -- from here or from another device -- with
// nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside
// it, which is what not knowing looks like: distinguishable from off, and from a refusal.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the
// switch above is: the row this dialog opened over is a snapshot, and a path drawn from it
// could be one somebody changed from another device. An empty answer is a session that was
// never given a directory, which is not the same as one whose directory is unknown -- the
// field is only enabled once one of those two is settled.
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 -- the session behind it shows nothing about it.
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 there is no changing one
// under a running session -- it is ended, and the next thing said to the session
// starts it in the new place.
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") } },
)
}
@@ -0,0 +1,685 @@
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) }
// The two settings on this screen that end the session's process, held while the reader is
// asked whether that is what they meant. Null is nobody being asked.
var askedCwd by remember(sessionId) { mutableStateOf<String?>(null) }
var askedEffort by remember(sessionId) { mutableStateOf<String?>(null) }
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.
*
* Only ever reached through [RestartDialog], which is where what it costs is said -- see
* `askToMove`.
*/
fun askToMove() {
val chosen = typedCwd.trim()
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
askedCwd = chosen
}
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. Asked for
* first, the same way a move is.
*
* 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: 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) }),
)
// 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.
hint = "wherever the session was started",
enabled = cwd != null && !movingCwd,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { askToMove() }),
modifier = Modifier.weight(1f),
)
TextButton(
onClick = { askToMove() },
enabled =
cwd != null &&
!movingCwd &&
typedCwd.trim().isNotEmpty() &&
typedCwd.trim() != cwd,
) {
Text(if (movingCwd) "Moving..." else "Move")
}
}
cwdError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
choices.forEach { choice ->
Spacer(Modifier.height(8.dp))
PickerRow(choice.label, choice.current, choice.options, 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))
PickerRow(
"Thinking",
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.
listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
) { chosen ->
askedEffort = chosen
}
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,
// The same picker the model and permission rows above use: how hard a
// session thinks is one kind of setting, whichever provider declares it.
choices = ChoiceStyle.Picker,
)
}
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") }
}
}
}
}
// A directory is settled when the process is spawned, so moving means ending it. Said here
// rather than under the field, which is the rule the whole screen follows -- see
// [RestartDialog].
askedCwd?.let { chosen ->
RestartDialog(
title = "Move to $chosen?",
text =
"This stops the session's process. It starts again in the new directory with " +
"the next message, or with Start.",
confirm = "Move",
onConfirm = {
askedCwd = null
moveCwd()
},
onDismiss = { askedCwd = null },
)
}
// The same cost for the same reason: the CLI reads the level when it launches, and has no
// control request for changing one.
askedEffort?.let { chosen ->
RestartDialog(
title = "Think $chosen?",
text =
"This stops the session's process. It starts again with the next message, or " +
"with Start.",
confirm = "Change",
onConfirm = {
askedEffort = null
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
},
onDismiss = { askedEffort = null },
)
}
}
/**
* 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"
}
@@ -0,0 +1,74 @@
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,6 +15,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import java.time.Duration
@@ -34,20 +36,19 @@ sealed class SessionUsage {
/**
* This machine meters nothing, so there is no window to show.
*
* Separate from [Unavailable], and the distinction is the whole point: a session on `echo` or
* on a local llama.cpp has no paid quota at all, which is a fact about how it was set up and
* not a failure to find something out. The backend never asks such a machine, so it returns no
* snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of
* answering with the nearest available word. Drawn as nothing, because there is nothing.
* Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
* local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
* failure to find something out. The backend never asks such a machine, and reading that
* silence as "couldn't find out" is answering with the nearest available word.
*/
data object NotMetered : SessionUsage()
/**
* The question could not be answered, and why.
*
* Its own state because "we couldn't find out" and "none of it is used" are the pair that must
* never share an appearance: a bar sitting at zero because a machine is unreachable reads as
* plenty of headroom, which is the opposite of the truth.
* Its own state because "we couldn't find out" and "none of it is used" must never share an
* appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
* headroom.
*/
data class Unavailable(val why: String) : SessionUsage()
}
@@ -59,28 +60,34 @@ private const val REFRESH_MS = 60_000L
* One poll of every machine's limits, and the handle to ask again.
*
* A screen shows this answer in more than one place -- the bar under the session header, the colour
* of the button beside it, and the dialog that button opens -- and each of those used to fetch for
* itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole
* refresh interval old when the dialog opens with a fresh one, so the header read 42% while the
* screen over it read 47%, about a number somebody is deciding on. One feed per screen, and
* [refresh] moves both.
* of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
* Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
* old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
* 47%.
*/
class UsageFeed(
val snapshots: LoadState<List<UsageSnapshot>>,
/**
* A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed].
*/
/** A fetch is outstanding. Only ever true over an answer already shown. */
val refreshing: Boolean,
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit,
) {
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
fun forSetup(setup: String): SessionUsage =
when (val state = snapshots) {
/**
* What meters [session], and what that meter came back as. See [usageFor] for the states.
*
* A session rather than a machine, because a machine is not what is metered: one machine runs
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
*/
fun forSession(session: SessionSummary): SessionUsage {
// Settled without asking anybody: a session nothing meters has nothing to check, and
// "checking" is what the fetch's own states would say about it for as long as one is out.
val provider = session.usageProvider ?: return SessionUsage.NotMetered
return when (val state = snapshots) {
is LoadState.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, setup)
is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model)
}
}
}
/**
@@ -93,15 +100,15 @@ class UsageFeed(
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
var refreshing by remember { mutableStateOf(true) }
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh
// also resets the countdown to the next one rather than leaving one due immediately after.
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
// resets the countdown rather than leaving one due immediately after.
var asked by remember { mutableIntStateOf(0) }
LaunchedEffect(asked) {
while (true) {
refreshing = true
// Replaces the answer only once the next one is in hand: dropping back to Loading
// would blank a bar somebody is reading for the length of a round trip, and what was
// on screen is still the last thing the machine actually said.
// Replaces the answer only once the next one is in hand: dropping back to Loading would
// blank a bar somebody is reading for the length of a round trip, and what was on
// screen is still the last thing the machine actually said.
snapshots =
try {
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
@@ -120,14 +127,12 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
*
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows came back rather than the three Claude sends today -- the backend
* deliberately passes windows it does not recognise straight through, so a fourth one is a thing
* that happens rather than a thing to notice later.
* Taken over however many windows this session's provider returned rather than the three Claude
* sends today -- the backend passes windows it does not recognise straight through.
*
* Every state that is not a measurement takes the ordinary control colour instead. That is the
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind
* the button is where those say, in words, which one they are.
* unknown blue would say "measured, and fine" about a machine nobody could reach.
*/
@Composable
fun usageGlyphColour(usage: SessionUsage): Color =
@@ -139,35 +144,34 @@ fun usageGlyphColour(usage: SessionUsage): Color =
}
/**
* The five-hour window for the machine this session runs on, under the session's own header.
* The shortest usage window for the pool this session uses, under the session's own header.
*
* 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
* session's machine alone -- the dialog is still where every machine is compared.
*
* What it shows is the paid service's own metering, fetched from the machine that holds the
* account. It is never derived from what this app has watched go past: the transcript's token
* counts are a different quantity, measured differently, and a bar shaped like a quota gauge built
* out of them would be a guess wearing a measurement's clothes.
* What it shows is the paid service's own metering, never derived from what this app has watched go
* past: the transcript's token counts are a different quantity, measured differently, and a bar
* built out of them would be a guess wearing a measurement's clothes.
*/
@Composable
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
DebugStats.count("usage bar recomposed")
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
// happened to move would sit at a stale figure for hours.
var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(REFRESH_MS)
now = OffsetDateTime.now()
}
}
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
// would sit at a stale figure for hours.
val now = rememberUsageNow()
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would
// report a problem about a setup somebody chose, on every screen, forever.
if (usage is SessionUsage.NotMetered) {
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// a problem about a machine somebody chose, on every screen, forever.
//
// And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
// second and was then taken away. A row that has to be withdrawn is worse than one that
// arrives late.
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
return
}
@@ -178,24 +182,18 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
// Words, not a colour and not an empty bar: every one of these is a different kind of
// answer from "this much is used", and only words carry a difference in kind.
when (val state = usage) {
SessionUsage.NotMetered -> Unit
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
// Both handled above, before the row exists at all.
SessionUsage.NotMetered,
SessionUsage.Waiting -> Unit
is SessionUsage.Unavailable -> UsageNote("Usage unknown -- ${state.why}")
is SessionUsage.Known -> {
val window = state.windows.firstOrNull { it.kind == "session" }
val window = shortestUsageWindow(state.windows)
if (window == null) {
UsageNote("5-hour usage unknown -- no five-hour window reported")
UsageNote("Usage unknown -- no window duration was reported")
} else {
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),
)
UsageProgressIndicator(window, now, Modifier.weight(1f))
Text(
fiveHourLabel(window, now),
usageWindowLabel(window, now),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
@@ -206,6 +204,56 @@ 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. */
@Composable
private fun UsageNote(text: String) {
@@ -217,44 +265,103 @@ private fun UsageNote(text: String) {
}
/**
* "42% -- 2h 15m left": how much is gone, then how long what is left has to last.
* "42% -- 2h 15m left / 5h": how much is gone, then how long what is left has to last, then how
* long the whole window is.
*
* The percentage on its own does not answer the question it gets asked, which is whether to start
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
*
* The window's end has two missing cases and they are worded differently on purpose; see
* [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is
* no countdown to report and inventing one would be the same fault as inventing the number.
* The window's *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
* that is not running gets the percentage and nothing else.
*/
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
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)) {
// Between blocks the five-hour window has no reset time, and saying so is a fact about
// nothing: there is no window to run out. The percentage is the whole answer.
// Between blocks a window can have no reset time, and saying so is a fact about nothing:
// there is no window to run out. The percentage is the whole answer.
WindowEnd.NotRunning -> percent
WindowEnd.Unreadable -> "$percent · reset time unreadable"
is WindowEnd.Ends ->
// 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.
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
else "$percent · ${formatSpan(end.until)} left"
else "$percent · ${formatSpan(end.until)} left$outOf"
}
}
/**
* One machine's snapshot, out of every machine's.
* One meter's snapshot, out of every machine's: [machine]'s row for [provider].
*
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
* service on one machine.
*
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered.
*/
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
// No snapshot at all means the backend never asked, which it only does for a machine with
// nothing metered on it. That is a different answer from having asked and failed.
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
fun usageFor(
snapshots: List<UsageSnapshot>,
machine: String,
provider: String,
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") {
return SessionUsage.Unavailable(mine.detail ?: mine.state)
val why =
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)
}
/** 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,7 +15,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -47,15 +46,14 @@ fun SettingsScreen(
val context = LocalContext.current
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
// Never pre-filled from the stored token: this screen shouldn't be a
// way to read the credential back off the device.
// Never pre-filled from the stored token: this screen shouldn't be a way to read the credential
// back off the device.
var token by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val scanLauncher =
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
// Null contents means the user backed out of the scanner -- not an
// error, so nothing to report.
// Null contents means the user backed out of the scanner -- not an error.
val contents = result.contents ?: return@rememberLauncherForActivityResult
val settings = parseEnrollmentUri(contents.toUri())
if (settings == null) {
@@ -83,8 +81,8 @@ fun SettingsScreen(
// left-pointing arrow at the right edge, aimed across the title it sits beside.
//
// Absent rather than disabled on first run, which is the one place this app lets a
// control come and go: there is no screen underneath yet, so a Back here would not be
// a capability being withheld but a promise it could not keep.
// control come and go: there is no screen underneath yet, so a Back here would not be a
// capability being withheld but a promise it could not keep.
if (onBack != null) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
@@ -106,14 +104,11 @@ fun SettingsScreen(
OutlinedButton(
onClick = {
// Hold the camera permission before the scanner starts.
// Letting its activity ask on our behalf is what the
// library does by default, and it opens the camera without
// waiting for the answer: the first-ever scan comes up as
// a live preview with "Sorry, the Android camera
// encountered a problem" over it, and works on the second
// try. Nothing is wrong with the camera, so nothing should
// say there is.
// Hold the camera permission before the scanner starts. Letting its activity ask on
// our behalf is what the library does by default, and it opens the camera without
// waiting for the answer: the first-ever scan comes up as a live preview with
// "Sorry, the Android camera encountered a problem" over it, and works on the
// second try.
if (
context.checkSelfPermission(Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
@@ -129,28 +124,14 @@ fun SettingsScreen(
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
LabelledField(label = "Host", value = host, onValueChange = { host = it })
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
LabelledField(label = "Port", value = port, onValueChange = { port = it })
Spacer(Modifier.height(8.dp))
OutlinedTextField(
LabelledField(
label = if (existing != null) "Token (unchanged if left blank)" else "Token",
value = token,
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))
@@ -187,11 +168,10 @@ fun SettingsScreen(
* just been granted.
*
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a
* dark-themed terminal it comes out as a photographic negative the scanner silently never matches.
* Which way round it renders is the terminal's business, not something this app should depend on.
* The mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* polarity and nothing else.
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark-
* themed terminal it comes out as a photographic negative the scanner silently never matches. The
* mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* polarity.
*/
private fun enrollmentScanOptions(): ScanOptions =
ScanOptions()
@@ -9,7 +9,7 @@ import androidx.core.content.IntentCompat
*
* Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the
* share arrives before anyone has said which. [serial] makes two shares of the same thing two
* requests, for the reason [SessionOpenRequest] carries one: equal values would not recompose.
* requests, for the reason [SessionOpenRequest] carries one.
*/
data class ShareRequest(val uris: List<Uri>, val text: String?, val serial: Int)
@@ -0,0 +1,250 @@
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()
}
}
@@ -4,15 +4,12 @@ package com.example.aiapp
* A byte count at the coarsest unit that still says something, so rows stay comparable.
*
* Null at zero and below, because the screens that ask disagree about what nothing means and only
* the caller knows: a transcript of no bytes is a measurement that has not happened, and is left
* off the row; a file of no bytes is a file with nothing in it, and the explorer says `0 B` rather
* than leaving a gap the reader would have to interpret; a session with no cached transcript says
* "nothing cached", because a figure of none would read as a measurement.
* the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no
* bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached
* transcript says "nothing cached", because a figure of none would read as a measurement.
*
* Its own file rather than the import screen's, where it started: three screens now say a size, and
* a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
* `ModelsScreen`'s `gigabytes` is deliberately not folded in -- it writes a download's size to two
* decimal places, which is a different question about a much larger number.
*/
fun humanSize(bytes: Long): String? =
when {
@@ -16,7 +16,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
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
@@ -37,7 +36,7 @@ import kotlinx.coroutines.withContext
* The spawn screen: what to run, where to run it, and the per-kind fields.
*
* Providers and hosts both come from the server, so adding either to its config.ron shows up here
* with no app rebuild -- and because they are independent, any provider can be sent to any host.
* with no app rebuild.
*/
@Composable
fun SpawnScreen(
@@ -46,52 +45,54 @@ fun SpawnScreen(
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
// What the form is made of, and whether we have it yet. A failure here
// is not the same as a server with nothing to offer, so it must not
// reach the pickers as empty lists -- see LoadState.
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
// What the form is made of, and whether we have it yet. A failure here is not the same as a
// server with nothing to offer, so it must not reach the pickers as empty lists.
var options by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
// Setup first, then one of its providers. Choosing a setup can
// invalidate the provider, so the provider is stored by name and
// resolved against the current setup rather than held as an object
// that could outlive the list it came from.
var setupName by remember { mutableStateOf<String?>(null) }
// Machine first, then one of its providers. Choosing a machine can invalidate the provider, so
// the
// 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.
var machineName by remember { mutableStateOf<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) }
var title 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("") }
// "auto" rather than "manual": on a phone every ask is a round trip to
// a question card, and answering "allow Bash?" dozens of times per task
// is what this app exists to avoid. Manual stays one tap away for a
// session that warrants it.
var permissionMode by remember { mutableStateOf("auto") }
// Set only after the selected provider reports its own default. An empty value is not sent.
var permissionMode by remember { mutableStateOf("") }
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
// arrives is one you can spawn at without having chosen it.
var effort by remember { mutableStateOf<String?>(null) }
var defaultsAsked by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
// Only the spawn's own failure. The fetch's lives in `options`: this
// one leaves a filled-in form worth keeping, and that one leaves
// nothing to fill in.
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) }
// Downloaded models, for a llama provider to choose between. Fetched
// beside the setups but kept separate: a Claude session needs none, so
// failing to list them must not stop the screen rendering.
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("") }
// Whatever the chosen provider says it takes, by key. Empty until something is typed: an
// absent key means the server's own default, which is what every field's placeholder says.
var params by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
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 =
try {
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) }
val first = fetched.firstOrNull()
setupName = first?.name
machineName = first?.name
providerName = first?.providers?.firstOrNull()?.name
LoadState.Loaded(fetched)
} catch (e: ApiException) {
LoadState.failed(e)
}
models =
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
.getOrDefault(emptyList())
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
@@ -105,11 +106,10 @@ fun SpawnScreen(
}
Spacer(Modifier.height(16.dp))
// Nothing below is fillable until the options are here, and a
// failure to fetch them leaves no form worth showing -- so this
// reports and stops, rather than offering empty pickers under an
// error message.
val setups =
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
// no form worth showing -- so this reports and stops, rather than offering empty pickers
// under an error message.
val machines =
when (val state = options) {
is LoadState.Loading -> {
CircularProgressIndicator()
@@ -121,52 +121,88 @@ fun SpawnScreen(
}
is LoadState.Loaded -> state.value
}
val setup = setups.firstOrNull { it.name == setupName }
val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and
// permission modes; keying the extra fields on the kind rather
// than the provider name keeps a second Claude provider from
// needing anything here.
val machine = machines.firstOrNull { it.name == machineName }
val current = machine?.providers?.firstOrNull { it.name == providerName }
// Coding CLIs take a working directory, model, permission mode and thinking level. Keying
// the extra fields on the kind rather than the provider name keeps a second installation
// from needing anything here.
val isClaude = current?.kind == "claude_cli"
val isCodex = current?.kind == "codex_cli"
val isCodingCli = isClaude || isCodex
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.
ChipGroup(
label = "Setup",
options = setups.map { it.name },
selected = setupName,
label = "Machine",
options = machines.map { it.name },
selected = machineName,
onSelect = { name ->
setupName = name
// The provider list changes with the machine, so a name
// carried over from the previous one would be a selection
// that isn't in the picker. Take that machine's first.
machineName = name
// The provider list changes with the machine, so a name carried over from the
// previous one would be a selection that isn't in the picker. Take that machine's
// first.
providerName =
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
},
)
setup?.address?.let {
machine?.address?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// The address belongs to the setup above it, not to the
// provider label below; without this they read as one block.
// The address belongs to the machine above it, not to the provider label below; without
// this they read as one block.
Spacer(Modifier.height(8.dp))
}
// Only what this machine actually has. A setup with none says so
// rather than showing an empty row that reads as a failure.
if (setup != null && setup.providers.isEmpty()) {
// Only what this machine actually has. A machine with none says so rather than showing an
// empty row that reads as a failure.
if (machine != null && machine.providers.isEmpty()) {
Text(
"\"${setup.name}\" has no providers configured.",
"\"${machine.name}\" has no providers configured.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Provider",
options = setup?.providers?.map { it.name }.orEmpty(),
options = machine?.providers?.map { it.name }.orEmpty(),
selected = providerName,
onSelect = { providerName = it },
)
@@ -174,93 +210,116 @@ fun SpawnScreen(
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
LabelledField(label = "Title", value = title, onValueChange = { title = it })
if (isLlama) {
// A llama session names one of the models this backend has
// downloaded, so the choice is that list rather than free
// text -- there is nothing sensible to type here, and a name
// that is not on disk is a session that cannot start.
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 },
)
if (offersModels) {
when {
providerModelsLoading ->
Text(
"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))
}
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(),
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))
}
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))
// 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 },
// Chips, like every other choice on this form.
choices = ChoiceStyle.Chips,
)
OutlinedTextField(
// Every session whose tools act on files needs one, which is both kinds that have
// tools -- a llama session's built-in tools run in it exactly as a CLI's do.
if (takesCwd) {
LabelledField(
label = "Working directory",
value = cwd,
onValueChange = { cwd = it },
label = { Text("Working directory") },
placeholder = { Text("/home/…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
hint = "wherever the session's process starts",
)
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(
label = "Permissions",
options = PERMISSION_MODES,
options = current.permissionModes,
selected = permissionMode,
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))
@@ -278,38 +337,29 @@ fun SpawnScreen(
try {
val spawned =
withContext(Dispatchers.IO) {
// Stored before the spawn and not after it: choosing a level is
// an intent about new sessions in general, so a spawn that then
// fails must not also lose the choice. Non-fatal for the same
// reason the fetch above is -- the session is what was asked for.
if (isCodingCli) {
runCatching { setDefaultEffort(settings, effort) }
}
spawnSession(
settings,
// The id, not the label: labels are
// editable and the server resolves by
// id.
// Non-null here: `chosen` came from
// `setup`'s own provider list, so
// reaching this point proves there was
// a setup to take it from.
setup = setup.id,
// The id, not the label: labels are editable and the server
// resolves by id. Non-null here, since `chosen` came from
// `machine`'s own provider list.
machine = machine.id,
provider = chosen.name,
title = title.trim(),
model =
if (isLlama) modelKey else model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
// Sent only when set, so blank means
// "whatever llama.cpp does by default"
// rather than a zero.
params =
buildMap {
if (isLlama) {
contextSize
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("contextSize", it) }
temperature
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("temperature", it) }
}
},
model = model.trim().takeIf { offersModels },
cwd = cwd.trim().takeIf { takesCwd },
permissionMode = permissionMode.takeIf { it.isNotEmpty() },
effort = effort.takeIf { isCodingCli },
// Already only the keys somebody set: a field left blank
// removes its key rather than sending an empty value, so
// "blank" reaches the server as "your default".
params = params,
)
}
onSpawned(spawned)
@@ -319,7 +369,8 @@ fun SpawnScreen(
}
}
},
enabled = !busy && current != null && !(isLlama && modelKey == null),
// A llama session names the file to load, so there is nothing to spawn without one.
enabled = !busy && current != null && !(isLlama && model.isEmpty()),
) {
Text(if (busy) "Spawning..." else "Spawn")
}
@@ -17,14 +17,13 @@ const val RECONNECT_DELAY_MS = 1500L
* One server-sent-events connection, framed.
*
* The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank
* line ends the frame, comments (keep-alives) start with `:`, and a frame is either named with no
* payload or a payload with no name. Two screens follow two different streams a session's
* transcript and what a machine's import list is doing and neither should be re-deriving that.
* line ends the frame, comments start with `:`, and a frame is either named with no payload or a
* payload with no name. Two screens follow two different streams and neither should re-derive that.
*
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
* cancellation path it disconnects the socket, which unblocks the read, and [run] then returns
* rather than throwing, so a deliberate close is not reported as a connection error. Reconnecting
* belongs to the caller, which is the only one that knows where to resume from.
* cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns
* rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where
* to resume from.
*/
class Sse(private val settings: ServerSettings) {
@Volatile private var connection: HttpURLConnection? = null
@@ -38,19 +37,16 @@ class Sse(private val settings: ServerSettings) {
/**
* Follows the stream at [path], handing each frame to [onFrame] as its name (null for an
* ordinary data frame) and its payload. The path is given here rather than at construction
* because a caller that reconnects usually resumes from somewhere new -- a cursor it has
* advanced past -- and that lives in the query string.
* because a caller that reconnects usually resumes from somewhere new.
*
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
* stream is live, and the only honest thing to clear a previous failure on: clearing on the
* first *event* instead left an idle stream displaying a connection error it had already
* recovered from, indefinitely.
* first *event* instead left an idle stream displaying an error it had already recovered from.
*/
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
// Opening is inside the try, not before it. Everything this method can fail at owes the
// caller the same kind of failure -- both callers retry an [ApiException] and let anything
// else reach the top of the app -- and a connection that could not even be constructed
// used to escape as a raw `IOException` from a line no `catch` covered.
// caller the same kind of failure, and a connection that could not even be constructed used
// to escape as a raw `IOException` from a line no `catch` covered.
var connection: HttpURLConnection? = null
try {
connection =
@@ -0,0 +1,335 @@
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"
}
@@ -44,16 +44,14 @@ private object Mocha {
*
* Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link*
* -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike
* is a preference, not a contract, and the moment one wants a different accent the shared version
* becomes a thing to fight rather than a thing to use.
* is a preference, not a contract.
*
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
* Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the
* page is Base, a component's outlined card stays Base beside it, and a project's card is Surface
* 0: one visible step up, which is the whole of what the nesting has to say.
* Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside
* it, and a project's card is Surface 0: one visible step up, which is the whole of what the
* nesting has to say.
*
* Accents on this palette are light, so anything filled with one takes Crust for its text rather
* than the near-white the roles default to.
* Accents on this palette are light, so anything filled with one takes Crust for its text.
*/
val AiAppColors =
darkColorScheme(
@@ -94,10 +92,8 @@ val AiAppColors =
* What a session is doing, said in colour.
*
* Here rather than beside each screen that shows a status. These were separate literals in two
* other files -- an amber, a green and a red picked off Material's defaults -- so the same state
* was a slightly different colour depending which screen you looked at, and none of them belonged
* to this palette at all. A colour that carries meaning is part of the scheme, not a value typed
* where it happened to be needed.
* other files, so the same state was a slightly different colour depending which screen you looked
* at. A colour that carries meaning is part of the scheme, not a value typed where it was needed.
*/
val runningColor: Color
@Composable get() = Mocha.Green
@@ -107,7 +103,7 @@ val runningColor: Color
*
* The scheme's error colour, and deliberately not "the same red as a destructive button" even
* though it is the same red. They are the same red for different reasons, and a state is not an
* action -- nothing here is a button.
* action.
*/
val failedColor: Color
@Composable get() = MaterialTheme.colorScheme.error
@@ -116,10 +112,9 @@ val failedColor: Color
* About the session rather than about the task: a command, and the compaction one of them starts.
*
* Its own colour because it is its own kind of work. Everything else a session does is progress
* through what was asked of it; this is the session acting on itself -- rewriting what it
* remembers, taking a new name -- and none of it appears in the transcript as an answer to
* anything. A reader who has learned that blue means "not stuck, but not replying to you either"
* has learned the thing that distinguishes it from a session that has hung.
* through what was asked of it; this is the session acting on itself, and none of it appears in the
* transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not
* replying to you either" has learned what distinguishes it from a session that has hung.
*/
val commandColor: Color
@Composable get() = Mocha.Blue
@@ -128,10 +123,9 @@ val commandColor: Color
* A clear: the conversation taken out of what the session is given.
*
* Red because of what it does, not because anything went wrong -- somebody asked for this, and a
* deliberate choice is not a problem to report. It is the same red as [failedColor] and [stopColor]
* for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a
* button, it is the mark left where something was taken away. The reader never has to tell the
* three apart, because no two of them can appear as the same kind of thing.
* deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a
* third reason: this is neither a fault nor a button, it is the mark left where something was taken
* away. No two of the three can appear as the same kind of thing.
*/
val clearedColor: Color
@Composable get() = Mocha.Red
@@ -140,6 +134,20 @@ val clearedColor: Color
val awaitingColor: Color
@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. */
val warningColor: Color
@Composable get() = Mocha.Yellow
@@ -148,9 +156,9 @@ val warningColor: Color
* The fill of a progress bar that is only reporting how far along something is.
*
* Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary
* made it the loudest thing on a screen the reader opened to do something else. A download, or a
* compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and
* that one is [quotaColor].
* made it the loudest thing on a screen the reader opened to do something else. A download has no
* limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is
* [quotaColor].
*/
val progressColor: Color
@Composable get() = Mocha.Blue
@@ -158,15 +166,13 @@ val progressColor: Color
/**
* The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red.
*
* One function rather than the same `when` written beside each bar, because the whole point of
* colouring by consequence is that the reader learns the step once -- two bars showing the same 80%
* in different colours teaches nothing except that the colour cannot be trusted. It reads as a
* difference in degree, which is all colour can carry: the states that differ in *kind* from this
* -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere,
* because a reader has no way to tell those from an ordinary low number by colour alone.
* One function rather than the same `when` written beside each bar, because the point of colouring
* by consequence is that the reader learns the step once. It reads as a difference in degree, which
* is all colour can carry: the states that differ in *kind* -- a window nobody could read, a
* machine that meters nothing -- are said in words elsewhere.
*
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
* without each converting it first and one of them getting it wrong by a factor of a hundred.
* without one of them getting it wrong by a factor of a hundred.
*/
@Composable
fun quotaColor(percent: Double): Color =
@@ -185,13 +191,11 @@ private const val OVER_LIMIT_PERCENT = 90.0
/**
* The surface verbatim text sits on: a command, a tool's output, a code block in a reply.
*
* The darkest value in the palette rather than a step up from the page, and that is the whole point
* -- everything else on this screen is somebody's prose, and this is what a machine was handed and
* The darkest value in the palette rather than a step up from the page, and that is the point --
* everything else on this screen is somebody's prose, and this is what a machine was handed and
* what it said back, character for character. Crust sits *below* Base, so the same colour reads as
* one clear step down both on the page, where a reply is drawn, and on a card, where a tool call
* is; a tint chosen upwards has to be picked twice and still collides with the card it lands on.
* The renderer's default code background was `surfaceVariant`, which is exactly a card's own fill
* -- so a code block inside a tool call had no background at all.
* one clear step down both on the page and on a card; a tint chosen upwards has to be picked twice
* and still collides with the card it lands on.
*
* One colour for all three, so "this is verbatim" is learnable once.
*/
@@ -202,14 +206,15 @@ val rawSurface: Color
* Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette].
*
* Here with the rest of the palette rather than beside the code that highlights: the colours a
* fence is drawn in are the same accents every other coloured thing in the app already uses, and
* splitting them out would make code the one surface whose palette came from somewhere else.
* fence is drawn in are the same accents every other coloured thing already uses.
*
* Not a composable, because [highlight] runs off the drawing thread; these colours never vary with
* the theme.
* Not a composable, because [highlight] runs off the drawing thread; these never vary with the
* theme.
*/
fun catppuccinSyntax(): SyntaxPalette =
SyntaxPalette(
addition = Mocha.Green,
deletion = Mocha.Red,
keyword = Mocha.Mauve,
string = Mocha.Green,
literal = Mocha.Peach,
@@ -227,8 +232,7 @@ fun catppuccinSyntax(): SyntaxPalette =
* already made for every other blue on the screen.
*
* Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which
* is upstream's choice and not an omission here. A program that uses bright red to mean something
* other than red is relying on a distinction its own terminal may not draw either.
* is upstream's choice and not an omission here.
*
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
* video needs to know what it is reversing against.
@@ -263,14 +267,12 @@ fun ansiPalette(): AnsiPalette =
*
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
* code block or a tool's output, on Crust, the same 40% composites to a barely-there smudge, so
* selecting a line of code looks like nothing happened even though the selection is there and
* copies correctly.
* code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of
* code looks like nothing happened even though it copies correctly.
*
* Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that
* means something must carry its own contrast instead of borrowing it from the surface it happens
* to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill
* starts competing with the syntax colours it sits behind, which are the thing being read.
* Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only
* as far as it takes to read on the darkest of them -- past this the fill starts competing with the
* syntax colours it sits behind.
*/
val AiAppSelectionColors =
TextSelectionColors(
@@ -288,11 +290,10 @@ val linkColor: Color
* A list's markers: the bullets and numbers down its left edge.
*
* The scheme's secondary accent rather than the text colour, because a marker is structure rather
* than words: coloured, the items of a list can be counted without reading them, and a nested list
* reads as a shape before it reads as text. Lavender is not one of the colours that mean something
* here -- green, red, peach and yellow are states and actions -- and it is the same at every depth,
* since depth is said by the glyph and the indent; a colour per depth would make a difference in
* degree look like one in kind.
* than words: coloured, the items of a list can be counted without reading them. Lavender is not
* one of the colours that mean something here, and it is the same at every depth, since depth is
* said by the glyph and the indent -- a colour per depth would make a difference in degree look
* like one in kind.
*/
val listMarkerColor: Color
@Composable get() = Mocha.Lavender
@@ -305,11 +306,9 @@ val overLimitColor: Color
* The composer's buttons, coloured by what pressing one does rather than by where it sits.
*
* Green makes something happen now, blue makes it happen later, orange takes back what is in
* flight, red ends the process. The near-collisions with the states above are deliberate and worth
* naming rather than collapsing: [runningColor] is green because a session is working,
* [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session
* is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell
* them apart, because nothing here is a state and nothing there is pressable.
* flight, red ends the process. The near-collisions with the states above are deliberate: those are
* *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is
* a state and nothing there is pressable.
*/
val sendColor: Color
@Composable get() = Mocha.Green
@@ -322,9 +321,8 @@ val queueColor: Color
* Interrupting the running turn: the work stops and the session stays.
*
* Orange rather than red because of how much it takes: only what is in flight. The process is still
* there holding the conversation, and the next message starts a turn as though nothing had
* happened. Red is spent on [stopColor], which is the same button in the same place when what it
* would end is the session's process.
* there holding the conversation. Red is spent on [stopColor], which is the same button in the same
* place when what it would end is the session's process.
*/
val pauseColor: Color
@Composable get() = Mocha.Peach
@@ -347,8 +345,7 @@ val startColor: Color
*
* The content colour is stated here beside the fill rather than inherited. A semantic colour has to
* carry its own contrast: these fills are fixed whatever the surface under them does, so the theme
* will not change to rescue a foreground that stops being readable on one of them. Crust is what
* every accent on this palette takes, which is the same reason `onPrimary` is Crust above.
* will not change to rescue a foreground that stops being readable on one of them.
*/
@Composable
fun actionButtonColors(fill: Color): ButtonColors =
@@ -0,0 +1,87 @@
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.runtime.CompositionLocalProvider
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,
replies: ParsedReplies,
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,
)
}
}
// Markdown, like every other thing the model wrote: a model reasons in the same
// lists, headings and fenced code it answers in, and drawn plainly those arrive as
// rows of hashes and asterisks around the working the reader opened the card to read.
// Still arriving means the incremental parse -- see [MarkdownText] -- since an open
// block gains a delta at a time.
//
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
if (expanded) {
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
MarkdownText(
item.text,
replies,
Modifier.padding(top = 6.dp),
live = item.open,
)
}
}
}
}
}
/**
* "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,9 +1,6 @@
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.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,11 +13,10 @@ import org.json.JSONObject
/**
* A tool call's input, read rather than dumped.
*
* Every tool's input arrives as JSON, and showing it raw makes the reader parse `{"command":"",
* "timeout":120000}` themselves to find the one line they care about. So the fields that carry the
* meaning are pulled out -- the command a shell will run, what it is for, how long it may take --
* and anything left over is still shown, because dropping a field would be claiming the tool has no
* other input when it might.
* Every tool's input arrives as JSON, and showing it raw makes the reader parse
* `{"command":"","timeout":120000}` themselves to find the one line they care about. So the fields
* that carry the meaning are pulled out, and anything left over is still shown, because dropping a
* field would be claiming the tool has no other input when it might.
*/
data class ToolInput(
/** The thing that will actually be run or read, if this tool has one. */
@@ -30,8 +26,8 @@ data class ToolInput(
/** The tool's own one-line summary, when it wrote one. */
val description: String?,
/**
* How long the call may take, in the largest units it fits ([formatMillis]). Shown apart
* because it is a limit on the call rather than part of what the call does.
* How long the call may take, in the largest units it fits. Shown apart because it is a limit
* on the call rather than part of what the call does.
*/
val timeout: String?,
/** Everything else, as `name: value` lines. Never dropped. */
@@ -47,29 +43,36 @@ data class ToolInput(
*
* A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them
* from being the special case that gets its own code path. Unknown tools fall through to "no
* subject, everything is rest", which is what the card always did.
* subject, everything is rest".
*/
private val SUBJECTS: Map<String, Pair<String, Language?>> =
mapOf(
"Bash" to ("command" to Language.SHELL),
"Shell" to ("command" to Language.SHELL),
"Patch" to ("diff" to Language.DIFF),
"Read" to ("file_path" to null),
"Write" to ("file_path" to null),
"Edit" to ("file_path" to null),
"Glob" to ("pattern" to null),
"Grep" to ("pattern" 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. */
private val DESCRIPTIONS = listOf("description", "prompt")
fun parseToolInput(tool: String, input: String): ToolInput {
if (input.trim() == "null") return ToolInput(null, null, null, null, emptyList())
val json =
try {
JSONObject(input)
} catch (_: org.json.JSONException) {
// Not an object: older transcripts and some tools send a bare
// string. It is still the input, so it is still shown.
// Not an object: older transcripts and some tools send a bare string. It is still the
// input, so it is still shown. JSON null is the one exception: it means the call had
// no input, and drawing the word makes an absent value look like an instruction.
return ToolInput(
null,
null,
@@ -79,15 +82,20 @@ fun parseToolInput(tool: String, input: String): ToolInput {
)
}
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() }
val subject =
subjectKey
?.let { json.text(it) }
?.takeIf { it.isNotBlank() }
?.let { if (tool == "Bash") renderedBashScript(it) ?: it else it }
val description = DESCRIPTIONS.firstNotNullOfOrNull {
json.optString(it).takeIf { v -> v.isNotBlank() }
json.text(it)?.takeIf { value -> value.isNotBlank() }
}
val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
val timeout = json.text("timeout")?.takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
val rest =
json
.keys()
.asSequence()
.filterNot(json::isNull)
.filter { it != subjectKey || subject == null }
.filter { it !in DESCRIPTIONS || description == null }
.filter { it != "timeout" || timeout == null }
@@ -97,12 +105,37 @@ fun parseToolInput(tool: String, input: String): ToolInput {
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.
*
* On the dark surface every verbatim thing in the app sits on -- see [RawBlock]. Drawn as nothing
* at all when the call carried neither, rather than as an empty block: a tinted rectangle with
* nothing in it is a rendering fault, and it is the shape a tool with no input actually has.
* On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the
* call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a
* rendering fault.
*
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
@@ -113,16 +146,16 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
if (parsed.subject == null && parsed.rest.isEmpty()) return
RawBlock(modifier) {
parsed.subject?.let { subject ->
// Not wrapped: a wrapped command hides where its arguments end,
// and the long one is the one being read closely.
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely. The sideways scroll that makes that readable is the block's,
// shared with the lines below -- see [RawBlock].
Text(
// Not cached: a tool's subject is one command line, which lexes in microseconds
// -- the cache exists for a fence with two hundred lines in it.
// Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it.
remember(subject, parsed.language) { highlight(subject, parsed.language) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
}
parsed.rest.forEach {
@@ -131,6 +164,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
modifier = Modifier.padding(top = 2.dp),
)
}
@@ -27,6 +27,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -42,14 +45,11 @@ import androidx.compose.ui.unit.dp
* the transcript's own order is what paging and the event stream depend on, and one screen's idea
* of "these belong together" must not reach back into it.
*
* Immutable, and said so, because Compose cannot tell.
*
* A row is a value: it is rebuilt from the transcript rather than edited, and two rows describing
* the same events are equal. Compose infers stability from a class's fields, and a `List` field --
* which several of these carry -- makes it assume the worst, so every composable taking one
* recomposed whenever anything above it did. A page of history landing recomposed all 148 loaded
* rows including the markdown inside them, measured as 701 compositions for 148 rows in one scroll,
* and that is what a page landing costs on top of the fetch itself.
* Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather
* than edited, and two rows describing the same events are equal. Compose infers stability from a
* class's fields, and a `List` field -- which several of these carry -- makes it assume the worst,
* so a page of history landing recomposed all 148 loaded rows including the markdown inside them,
* measured as 701 compositions for 148 rows in one scroll.
*
* The promise this makes is real and has to stay true: nothing here is mutated after it is built.
*/
@@ -59,16 +59,14 @@ sealed class TranscriptRow {
* This row's identity in the list, which must survive everything that can happen to the row.
*
* The list is keyed by this so that inserting a new message at one end, or a page of history at
* the other, moves the rows and not the reader. That makes it the load-bearing value on this
* screen: when a key changes, the list loses its anchor and the transcript steps under whoever
* is reading it.
* the other, moves the rows and not the reader. When a key changes, the list loses its anchor
* and the transcript steps under whoever is reading it.
*
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
* and it is the *same* value whether the run is drawn as one card or as a group. A lone call
* that gains a neighbour becomes a group without changing identity, which is the case a
* seq-based key got wrong: the row the reader was looking at was replaced rather than updated.
* Which value that is belongs to the item ([TranscriptItem.key]), not to a `when` here: a row
* is one item and the item is what knows what it is called.
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
* that is belongs to the item ([TranscriptItem.key]) everywhere a row is one thing; where
* [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
@@ -76,32 +74,21 @@ sealed class TranscriptRow {
* Where this row starts in the transcript: the sequence number of the oldest event behind it.
*
* Separate from [key], and deliberately so. [key] is the list's identity and is a display
* decision -- a tool row is named after its run, and a run takes its name from whichever call
* was first when it was folded, which changes as pages arrive. A seq is the server's own
* numbering: it is assigned once, never moves, and means the same thing to every device. So
* anything that has to point at a place in the conversation and still find it later -- a saved
* scroll position is the one -- points with this, and anything that has to identify a row
* within one composition uses [key].
* decision; a seq is the server's own numbering, assigned once and meaning the same thing to
* every device. So anything that has to point at a place in the conversation and still find it
* later -- a saved scroll position -- points with this.
*/
abstract val startSeq: Long
data class Single(val item: TranscriptItem) : TranscriptRow() {
override val key: Any
get() = item.key
data class Single(val item: TranscriptItem, override val key: Any = item.key) :
TranscriptRow() {
override val startSeq: Long
get() = item.seq
}
/** Two or more calls with nothing between them; drawn as one collapsed card. */
data class Tools(val calls: List<TranscriptItem.ToolRun>) : 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
data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) :
TranscriptRow() {
override val startSeq: Long
get() = calls.first().seq
}
@@ -112,34 +99,75 @@ sealed class TranscriptRow {
*
* 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.
*
* 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(items: List<TranscriptItem>): List<TranscriptRow> =
DebugStats.timed("grouped tool runs") { groupRuns(items) }
fun groupToolRuns(
items: List<TranscriptItem>,
heldOut: Set<String> = emptySet(),
): List<TranscriptRow> = DebugStats.timed("grouped tool runs") { groupRuns(items, heldOut) }
private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
private fun groupRuns(items: List<TranscriptItem>, heldOut: Set<String>): List<TranscriptRow> {
val rows = mutableListOf<TranscriptRow>()
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() {
when (run.size) {
0 -> {}
1 -> rows += TranscriptRow.Single(run.first())
else -> rows += TranscriptRow.Tools(run.toList())
val first = run.firstOrNull() ?: return
// The first piece keeps the run's name, which survives a page landing in front of it
// ([adoptRun]). Later pieces qualify that name with their first call; the suffix is the
// final guard because a duplicate LazyColumn key takes down the whole screen.
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()
}
items.forEach { item ->
items.forEachIndexed { index, item ->
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
// Adjacency is the same answer most of the time and a worse one at the edges: a call
// arriving next to an existing run, or a page of history arriving in front of one, both
// change which call is *first*, and a group named after its first member is a different
// group every time that happens.
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
run += item
} else {
// change which call is *first*.
val call = item as? TranscriptItem.ToolRun
if (call == null || call.runId != runId) {
flush()
if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
runId = call?.runId
}
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()
@@ -152,18 +180,14 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
* What says the calls belong together is the surface behind them, which is the one cue rather than
* two half-cues -- rounded to the same corner every other card in the app has, so a group reads as
* one object rather than as a square patch behind round things. The calls sit on it inset by
* [GROUP_INSET], which is the container's own padding rather than an indent: they are the same rows
* they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a
* notch.
* [GROUP_INSET], which is the container's own padding rather than an indent.
*
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
* the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without
* separating them. See [connectedShape].
* the run reads as one thing broken into its parts; see [connectedShape].
*
* It closes from either end. A long group's header scrolls off while its last call is still on
* screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. The
* bar at the foot is the same height as the heading at the top, so the surface the calls sit on is
* as thick below them as above.
* screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the
* same height as the heading at the top.
*/
@Composable
fun ToolGroup(
@@ -171,12 +195,19 @@ fun ToolGroup(
expanded: Boolean,
/**
* Where it was pressed is the row's business rather than the control's -- a group has a control
* at each end, and only the row knows where its own ends are, so the row records the touch
* itself and this just says that one happened.
* at each end, and only the row knows where its own ends are.
*/
onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean,
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,
image: @Composable (String) -> Unit,
) {
@@ -191,8 +222,10 @@ fun ToolGroup(
}
return
}
val placed = remember { Placed() }
Column(
Modifier.fillMaxWidth()
.onPlaced { placed.top = it.positionInRoot().y }
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
@@ -212,28 +245,46 @@ fun ToolGroup(
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
) {
group.calls.forEachIndexed { index, call ->
val card = remember(call.id) { Placed() }
ToolCard(
tool = call,
expanded = isToolExpanded(call.id),
onToggle = { onToolToggle(call.id) },
onToggle = {
onToolToggle(call.id, (card.top - placed.top).toInt(), card.height)
},
onAnswer = onAnswer,
image = image,
shape = connectedShape(index, group.calls.size),
modifier =
Modifier.onPlaced { card.top = it.positionInRoot().y }
.onSizeChanged { card.height = it.height },
)
}
}
// Shutting it from here anchors the other end: the reader is at the bottom of a long
// group, and what they are looking at is what follows it.
// Shutting it from here anchors the other end: the reader is at the bottom of a long group,
// and what they are looking at is what follows it.
CollapseBar(barHeight, onToggle)
}
}
/**
* 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.
*
* Derived from the type the heading is set in rather than written down, because the two have to
* match and a pair of numbers chosen to look equal stops being equal the moment either the style or
* the density changes. Taking the line height also means the heading cannot be clipped by it.
* match and a pair of numbers chosen to look equal stops being equal the moment the density
* changes.
*/
@Composable
private fun groupBarHeight(): Dp {
@@ -242,10 +293,9 @@ private fun groupBarHeight(): Dp {
}
/**
* The bottom half of a group's toggle: an arrow back up to its heading.
*
* Given the heading's height rather than padded to something that looks close, so the surface the
* calls sit on is the same thickness at both ends. See [groupBarHeight].
* The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height
* rather than padded to something that looks close, so the surface the calls sit on is the same
* thickness at both ends.
*/
@Composable
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
@@ -266,8 +316,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
* does not.
*
* Written once and given an index rather than branched at each end, because a stack has three cases
* that are one rule -- and the middle one is the case a hand-written first/last pair gets wrong
* when a run turns out to have three calls in it.
* that are one rule -- and the middle one is what a hand-written first/last pair gets wrong.
*/
@Composable
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
@@ -294,12 +343,10 @@ private val GROUP_GAP = 2.dp
* One tool call.
*
* Closed, it is a single line: the tool's name and what the call is for. The command itself is not
* on it, because a wrapped command turns one row into four and a run of them into a wall -- and the
* name plus the intent is what somebody scanning the transcript is reading for.
* on it, because a wrapped command turns one row into four and a run of them into a wall.
*
* Open, it shows the command, whatever else the input carried, and the output. The timeout sits at
* the top right: it is a limit on the call rather than part of what the call does, and it is worth
* seeing beside the command it constrains rather than buried in the fields below it.
* the top right: it is a limit on the call rather than part of what the call does.
*
* A call waiting on permission is shown open whatever the reader last chose, since the command is
* the thing being decided and a row saying only "Bash" cannot be decided on.
@@ -313,14 +360,17 @@ fun ToolCard(
image: @Composable (String) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape,
modifier: Modifier = Modifier,
) {
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 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)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
Text(name, style = MaterialTheme.typography.titleSmall)
if (open) {
Spacer(Modifier.weight(1f))
parsed.timeout?.let {
@@ -342,10 +392,9 @@ fun ToolCard(
)
} ?: Spacer(Modifier.weight(1f))
}
// A spinner says the machine is working. While this call is waiting on an
// answer the machine is doing nothing at all -- the turn is stopped on the
// person reading it -- so it says whose move it is instead, in the colour this
// app uses everywhere for that.
// A spinner says the machine is working. While this call is waiting on an answer
// the machine is doing nothing at all -- the turn is stopped on the person reading
// it -- so it says whose move it is instead.
if (deciding) {
Spacer(Modifier.width(8.dp))
Text(
@@ -370,40 +419,38 @@ fun ToolCard(
modifier = Modifier.padding(top = 4.dp),
)
}
// Everything AskUserQuestion carries is the questions, and those are drawn
// below as something answerable; dumping the same JSON above them would be the
// decision stated twice, once unreadably.
// Everything AskUserQuestion carries is the questions, and those are drawn below as
// something answerable; dumping the same JSON above them would be the decision
// stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
}
if (tool.output.isNotEmpty()) {
if (output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall)
// What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is
// prose -- a directory listing, a diff, a table of numbers -- and a
// proportional font silently destroys the alignment that carried the meaning.
// prose, and a proportional font silently destroys the alignment that carried
// the meaning. Unwrapped for the same reason, and scrolled sideways by the
// block around it -- see [RawBlock].
//
// Its terminal styling applied and the rest of the escapes taken out, since
// what a shell prints is written for a terminal: colour is often the whole of
// what a diff or a test run is saying, and the sequences that carry it are
// unreadable drawn verbatim. Remembered against the text, so a card that is
// open through a scroll parses once. See [ansiStyled].
// Its terminal styling applied and the rest of the escapes taken out: colour is
// often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() }
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
val styled = remember(output, palette) { ansiStyled(output, palette) }
RawBlock(Modifier.padding(top = 2.dp)) {
Text(
styled,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
)
}
}
}
// Shown open or closed. A call that produced a picture is one
// whose result *is* the picture, and a row that hides it says
// less than the one line it replaced -- unlike a command, which
// is what the closed line already summarises.
// Shown open or closed. A call that produced a picture is one whose result *is* the
// picture, and a row that hides it says less than the one line it replaced.
tool.images.forEach { ref -> image(ref) }
if (tool.asks.isNotEmpty()) {
if (tool.tool == ASK_USER_QUESTION) {
@@ -416,6 +463,22 @@ 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.
*
@@ -428,11 +491,10 @@ private fun PermissionAsk(
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) {
// What was pressed, before the answer has been round-tripped. Two bare words with no submit
// step -- unlike a question card, where the answer is several choices and worth reviewing --
// so the press has to be its own acknowledgement or the row sits unchanged for a round trip
// and reads as having missed the tap. Cleared when the request settles: by then either the
// answer is in `ask.answers` and the mark stands on a measurement, or it failed and the
// buttons come back rather than leaving a decision marked that nothing recorded.
// step -- unlike a question card, where the answer is worth reviewing -- so the press has to be
// its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request
// settles: by then either the answer is in `ask.answers`, or it failed and the buttons come
// back.
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
Spacer(Modifier.height(8.dp))
Text(
@@ -442,8 +504,7 @@ private fun PermissionAsk(
)
// Answered or not, the options stay and the one that was taken is marked -- see
// [AskedQuestion], which is the same rule on the question card. A permission is where it
// matters most: "Answered: Deny" alone does not say that Allow was the alternative, and
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
// matters most: "Answered: Deny" alone does not say that Allow was the alternative.
val settled = ask.answers.isNotEmpty()
AnswerOptions(
ask.options,
@@ -0,0 +1,27 @@
package com.example.aiapp
/**
* Where one transcript lives: a session's own, or one of its subagents'.
*
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
*/
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
val urlPath: String
get() =
if (subagentId == null) "sessions/$sessionId"
else "sessions/$sessionId/subagents/$subagentId"
/**
* Where this transcript's cache lives on the phone, relative to the cache root.
*
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
* session's cache directory takes its subagents' with it -- the same one-way door the server's
* own storage describes.
*/
val cachePath: String
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
}
@@ -11,44 +11,44 @@ import java.io.RandomAccessFile
* This phone's copy of the transcripts it has already been sent, so reopening a session does not
* download it again.
*
* What is stored is the server's own JSON for one event per line, in transcript order -- the
* elements of a `/transcript` page and the payload of each SSE frame. Reading the cache means
* running the same [parseSeqEvent] the network path runs, so a cached transcript and a fetched one
* cannot draw differently, and an event type this build does not know ([SessionEvent.Unknown])
* keeps every field it arrived with, on disk, for the build that will. Rows are deliberately *not*
* what is stored: a row is a rendering of events, its shape changes whenever the fold does, and a
* cache of rows would need throwing away on every app update that touched `foldEvent`.
* What is stored is the server's own JSON for one event per line, in transcript order. Reading the
* cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a
* fetched one cannot draw differently, and an event type this build does not know keeps every field
* it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a
* rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`.
*
* See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it:
* 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the
* cache is a copy and is never inferred, folded or edited here;
* 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache;
* 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a
* cold open, never to a blank or a wrong screen;
* 4. a line already on the phone is not fetched again.
* cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched
* again.
*
* A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs
* under the JVM unit tests. It is also why there is no JSON parser in here: what it needs off a
* line is the sequence number and whether the line is a streamed delta, and both are read with a
* regex over text the server wrote. A line it cannot read that way is treated as damage, which
* gives the same answer as having no cache at all.
*
* [warn] is where failures are said, for the same reason -- `android.util.Log` is a stub that
* throws under the JVM tests, and this file has to be exercisable there.
* under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line
* is the sequence number and whether the line is a streamed delta, both read with a regex. A line
* it cannot read that way is treated as damage. [warn] is where failures are said for the same
* reason.
*/
class TranscriptCache(
private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/** The cache for one session, whether or not anything has been stored for it yet. */
fun session(id: String): SessionCache = SessionCache(File(root, id), warn)
/**
* The cache for one transcript, whether or not anything has been stored for it yet.
*
* A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so
* deleting the session (below) takes its subagents' caches with it -- there is no separate
* purge for one.
*/
fun session(address: TranscriptAddress): SessionCache =
SessionCache(File(root, address.cachePath), warn)
/**
* Deletes every session directory not in [ids], called after a successful list fetch.
*
* The path out for a session deleted on another device or at the backend: nothing here would
* otherwise ever hear about it, and unlike a draft's few bytes what it leaves behind is
* megabytes.
* Deletes every session directory not in [ids], called after a successful list fetch. The path
* out for a session deleted on another device: nothing here would otherwise hear about it, and
* unlike a draft's few bytes what it leaves behind is megabytes.
*/
fun retainOnly(ids: Set<String>) =
guardIo(Unit, warn) {
@@ -57,11 +57,9 @@ class TranscriptCache(
/**
* Deletes least-recently-touched session directories, never [keep], until the whole of this
* server's cache is under [budget].
*
* Least-recently-touched rather than largest: what a reader is likely to open again is what
* they opened last, and evicting the big ones first would empty the cache for exactly the
* conversations it exists for.
* server's cache is under [budget]. Least-recently-touched rather than largest: what a reader
* is likely to open again is what they opened last, and evicting the big ones first would empty
* the cache for exactly the conversations it exists for.
*/
fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) =
guardIo(Unit, warn) {
@@ -81,18 +79,16 @@ class TranscriptCache(
}
/**
* How much of this phone's cache directory all of one server's transcripts may take.
*
* A dozen of the largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small
* fraction of a phone. A number to revisit against real use rather than a measurement of anything.
* How much of this phone's cache directory all of one server's transcripts may take. A dozen of the
* largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone.
* A number to revisit against real use rather than a measurement of anything.
*/
const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000
/**
* What the newest cached line says, which is what the probe checks against the server.
*
* Both halves are wanted together and by the same caller: the seq is what the request asks about,
* and the line is what its answer is compared with.
* What the newest cached line says, which is what the probe checks against the server. Both halves
* are wanted together: the seq is what the request asks about, and the line is what its answer is
* compared with.
*/
data class CachedTail(val seq: Long, val line: String)
@@ -101,35 +97,31 @@ data class CachedTail(val seq: Long, val line: String)
*
* A chunk is a set of lines *and a claim about what they cover*, and the two are not the same
* thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the
* run's oldest delta, so a page whose newest event is seq 1,200 may in fact cover everything up to
* the 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open
* range in the file's name:
* run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the
* 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range
* in the file's name:
* ```
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run
* <first>-open.raw.jsonl the live run; end is its last line's seq + 1
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run <first>-open.raw.jsonl the
* live run; end is its last line's seq + 1
* ```
*
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run of
* adjacent chunks ending at the newest chunk -- the **suffix** -- is ever served: chunks behind a
* gap are kept, because the gap is usually closed by paging back through it, but nothing is served
* across one.
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending
* at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because
* the gap is usually closed by paging back through it, but nothing is served across one.
*
* **The newest chunk is always raw**, which is what makes the stream cursor and the probe well
* defined -- a raw chunk's last line is a real event at a real seq, and the server never coalesces
* the newest window. It holds by construction (the opening window and every stream frame are raw)
* and is checked on read: a `.rows` chunk at the newest end can only mean this app died between
* closing one live run and opening the next, and it discards the session.
* defined. It holds by construction (the opening window and every stream frame are raw) and is
* checked on read: a `.rows` chunk at the newest end can only mean this app died between closing
* one live run and opening the next, and it discards the session.
*
* Nothing here is load-bearing. Every operation that touches the disk answers as though the cache
* were empty when it cannot, and a write failure disables writing for the rest of this instance's
* life so that a full disk costs one log line rather than one per delta.
*
* Every operation is synchronized, because two of them really do run at once: the stream appends
* live events from its own IO thread while a reader scrolling back reads pages from another. The
* lock is uncontended in the ordinary case and what it buys is that the open chunk's name, its end
* and its writer are never read half-rotated -- which would show up as a page silently fetched
* again, or as a stored chunk overlapping the run it was written beside.
* live events from its own IO thread while a reader scrolling back reads pages from another. What
* it buys is that the open chunk's name, its end and its writer are never read half-rotated.
*/
class SessionCache(
private val dir: File,
@@ -141,9 +133,8 @@ class SessionCache(
* The open chunk's writer, its file, and the seq that chunk now ends at.
*
* Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of
* times a second while a reply streams -- a syscall each is the thing to avoid. What that costs
* is the unflushed tail on a crash, which is safe: a shorter cache is a longer catch-up, never
* a wrong one.
* times a second while a reply streams. What that costs is the unflushed tail on a crash, which
* is safe: a shorter cache is a longer catch-up, never a wrong one.
*/
private var writer: BufferedWriter? = null
private var openFile: File? = null
@@ -153,8 +144,7 @@ class SessionCache(
* The newest line of the suffix, or null when there is none or the newest chunk is not raw.
*
* This is the cursor the live stream would resume from, so it is also what has to be shown to
* still be the server's own line before anything is resumed from it -- see
* `TranscriptSource.probe`.
* still be the server's own line before anything is resumed from it.
*/
@Synchronized
fun tail(): CachedTail? =
@@ -187,30 +177,26 @@ class SessionCache(
* The page of lines before [before], oldest first, or null when the cache cannot answer.
*
* Null is a miss -- the suffix does not cover the ground immediately below [before] -- and
* means the server has to be asked. It is deliberately not an empty list: an empty page is how
* the screen is told it has reached the start of the conversation, and a cache saying that of
* means the server has to be asked. Deliberately not an empty list: an empty page is how the
* screen is told it has reached the start of the conversation, and a cache saying that of
* history it merely does not hold would stop the transcript scrolling back for good.
*
* [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open
* leaves behind is in the middle of the live run -- the screen draws the newest eighty lines of
* it -- so a cache that could only answer at a boundary would send the very first backwards
* page to the server and, since that page would overlap the run, keep none of it.
* leaves behind is in the middle of the live run, so a cache that could only answer at a
* boundary would send the very first backwards page to the server and, since that page would
* overlap the run, keep none of it.
*
* A short page is fine, and is what a walk that reaches the oldest chunk of the suffix returns:
* the caller already treats a short page as a page.
*
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`:
* every event that is not a streamed delta is a row, and each maximal run of deltas is one row.
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`.
* The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of
* its first delta either way, so anchors and the next `before` land where they do today.
* its first delta either way.
*/
@Synchronized
fun page(before: Long, limit: Int, rows: Boolean): List<String>? =
guard(null) {
val suffix = suffix()
val newest = suffix.lastOrNull() ?: return@guard null
// Above what is held, or at or below where it starts: either way the run the caller
// is scrolling into is not continuous with this one, and only the server has it.
// Above what is held, or at or below where it starts: either way the run the caller is
// scrolling into is not continuous with this one, and only the server has it.
if (before > newest.end || before <= suffix.first().first) return@guard null
val taken = ArrayDeque<String>()
var counted = 0
@@ -220,14 +206,14 @@ class SessionCache(
if (!wanting) break
if (chunk.first >= before) continue
eachLine(chunk) { line ->
// The page is what is *before* the cursor; the rows at or above it are the
// ones already on screen.
// The page is what is *before* the cursor; the rows at or above it are already
// on screen.
if (seqOf(line)!! >= before) return@eachLine true
if (rows) {
val delta = isDelta(line)
// Stop only between rows: a delta continuing the run being gathered is
// part of a row already counted, and breaking on it would drop the half
// of that row already taken.
// Stop only between rows: a delta continuing the run being gathered is part
// of a row already counted, and breaking on it would drop the half of that
// row already taken.
if (counted >= limit && !(delta && inRun)) wanting = false
else {
if (!delta || !inRun) counted++
@@ -248,8 +234,7 @@ class SessionCache(
* asked with so that it stops where this phone's copy starts. Null when there is no such chunk.
*
* Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that
* the gap is closed with exactly the bytes it is wide and the history behind it is served
* locally from then on.
* the gap is closed with exactly the bytes it is wide.
*/
@Synchronized
fun coveredUpTo(before: Long): Long? =
@@ -260,9 +245,8 @@ class SessionCache(
*
* Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced
* event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway --
* from a server without the `after` parameter, say. Such a page is still drawn; it is only not
* kept.
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway.
* Such a page is still drawn; it is only not kept.
*
* The newest chunk is never stored through here: the opening window and every live frame go
* through [append], which is what keeps the newest chunk raw and open.
@@ -282,18 +266,17 @@ class SessionCache(
* Appends one live event, which is also how a freshly fetched opening window is stored.
*
* A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a
* `reset` looks like from here -- and closes the open chunk under the end it turned out to have
* before starting a new one at [seq]. A smaller one is already covered and is ignored; the SSE
* contract is `seq > after`, so that is a guard rather than a path.
* `reset` looks like from here -- and closes the open chunk under the end it turned out to
* have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`.
*/
@Synchronized
fun append(line: String, seq: Long) =
guard(Unit) {
if (disabled) return@guard
val writer = writerFor(seq) ?: return@guard
// Written as it arrived. A newline inside it would split one event into two
// unreadable halves, but neither source can produce one: SSE framing forbids it, and
// a page's elements are re-serialized compactly, which escapes it.
// Written as it arrived. A newline inside it would split one event into two unreadable
// halves, but neither source can produce one: SSE framing forbids it, and a page's
// elements are re-serialized compactly, which escapes it.
writer.write(line)
writer.write("\n")
openEnd = seq + 1
@@ -329,9 +312,7 @@ class SessionCache(
/**
* Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored.
*
* Recomputed per operation rather than kept: another operation may have changed the directory,
* and a hundred names is a directory listing.
* Recomputed per operation rather than kept: another operation may have changed the directory.
*/
private fun chunks(): List<Chunk> {
writer?.flush()
@@ -354,9 +335,9 @@ class SessionCache(
* is the one writing it.
*
* An open chunk whose last line cannot be read is this app having died mid-write. That line is
* dropped and the file truncated to the last good one before anything is served from it, which
* is the one place damage is repaired rather than discarded: the tail of an append-only file is
* the only place a partial line can be.
* dropped and the file truncated to the last good one, which is the one place damage is
* repaired rather than discarded: the tail of an append-only file is the only place a partial
* line can be.
*/
private fun openEndOf(file: File, first: Long): Long {
if (openFile == file && openEnd > 0) return openEnd
@@ -373,8 +354,7 @@ class SessionCache(
* The contiguous run of adjacent chunks ending at the newest one, oldest first.
*
* A newest chunk that is not raw cannot happen while this code is the only writer, and means
* the directory is not to be trusted -- so the session is discarded rather than served across
* whatever else is wrong with it.
* the directory is not to be trusted -- so the session is discarded.
*/
private fun suffix(): List<Chunk> {
val all = chunks()
@@ -393,10 +373,9 @@ class SessionCache(
/**
* Each line of [chunk], newest first, until [take] says stop.
*
* Backwards and lazily, because every question this cache is asked is about the newest end --
* the tail, the opening window, the page before a cursor -- and a live run grows to the size of
* the conversation. Reading the file whole to answer with eighty lines of it is the cost the
* server's own reader was rewritten to stop paying.
* Backwards and lazily, because every question this cache is asked is about the newest end and
* a live run grows to the size of the conversation. Reading the file whole to answer with
* eighty lines of it is the cost the server's own reader was rewritten to stop paying.
*
* Damage anywhere but at the tail of the open chunk was not written by this code, and there is
* no honest way to say what a chunk covers with a line of it unreadable -- so it discards the
@@ -433,8 +412,8 @@ class SessionCache(
}
rename(existing.file, existing.first, existing.end)
}
// A chunk that was created and never written to would otherwise be left behind under a
// name a second one is about to want; it covers nothing, so nothing is lost with it.
// A chunk that was created and never written to would otherwise be left behind under a name
// a second one is about to want; it covers nothing, so nothing is lost with it.
dir.listFiles().orEmpty().forEach {
if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L)
it.delete()
@@ -480,12 +459,12 @@ class SessionCache(
*
* None of this is reported on screen: none of it changes what the screen shows -- every read
* here has a network path beside it producing the same result -- and the reader has nothing to
* do about it. It is logged, and damage discards this session's cache, which is what makes the
* next open an ordinary cold one.
* do about it. Damage discards this session's cache, which makes the next open an ordinary cold
* one.
*/
private fun <T> guard(ifBroken: T, body: () -> T): T =
// A disk that refused once will refuse again, once per delta, so the first refusal is
// also the last: this instance stops writing rather than logging a line a token.
// A disk that refused once will refuse again, once per delta, so the first refusal is also
// the last: this instance stops writing rather than logging a line a token.
guardIo(
ifBroken,
warn,
@@ -515,8 +494,7 @@ private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""")
* One line's sequence number, or null when the line is not one of ours.
*
* A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM
* tests: the seq is the first field the server writes (`SeqEvent`'s declaration order, with the
* event flattened after it), so the first match is the top-level one.
* tests: the seq is the first field the server writes, so the first match is the top-level one.
*/
private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull()
@@ -536,11 +514,10 @@ private const val READ_BLOCK = 64 * 1024
*
* Every question the cache is asked is about the newest end of a chunk, and a live run reaches the
* size of the conversation, so reading forwards means reading a transcript to answer with the last
* eighty lines of it. This reads blocks from the end and stops where the caller stops.
* eighty lines of it.
*
* Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte
* UTF-8 sequence; each line is decoded whole, so nothing is cut through a character. A missing file
* yields nothing, which is the same answer as an empty one.
* UTF-8 sequence; each line is decoded whole. A missing file yields nothing.
*/
private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) {
if (!file.isFile) return
@@ -581,8 +558,8 @@ private const val NEWLINE = '\n'.code.toByte()
* Drops a final line that is not one of ours, by truncating the file to where it starts.
*
* This app having died mid-write is the one kind of damage that is repaired rather than discarded:
* the tail of an append-only file is the only place a partial line can be, and everything before it
* is intact. A second bad line is not this, and is left for the read path to notice.
* the tail of an append-only file is the only place a partial line can be. A second bad line is not
* this, and is left for the read path to notice.
*/
private fun repairTail(file: File) {
var truncateTo = -1L
@@ -598,9 +575,7 @@ private fun sizeOf(file: File): Long =
/**
* The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance.
*
* [onFailure] is what the caller does about it beyond answering [ifBroken] -- for a session's
* cache, giving up on writing.
* [onFailure] is what the caller does about it beyond answering [ifBroken].
*/
private fun <T> guardIo(
ifBroken: T,
@@ -9,10 +9,9 @@ import kotlinx.coroutines.withContext
*
* Events are the only data source, and there is deliberately no second shape for history to drift
* from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are
* all the same events through the same parser. Since 2026-09-04 the cache is where most of them
* come from on a session opened again -- see [TranscriptCache], which stores the server's lines
* rather than these rows for exactly that reason: a row is a rendering, and its shape changes
* whenever this file does.
* all the same events through the same parser. [TranscriptCache] stores the server's lines rather
* than these rows for exactly that reason -- a row is a rendering, and its shape changes whenever
* this file does.
*/
@Immutable
sealed class TranscriptItem {
@@ -24,10 +23,8 @@ sealed class TranscriptItem {
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
* content underneath it slides, which reads as the view scrolling on its own.
*
* A seq is the right identity because it is what the transcript itself is ordered by, it never
* changes, and it is already carried by every event. A row built from several events -- a
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
* still while the rest of it arrives.
* A row built from several events keeps the seq of the first, so it holds still while the rest
* of it arrives.
*/
abstract val seq: Long
@@ -35,10 +32,8 @@ sealed class TranscriptItem {
* This item's identity on screen, which is its [seq] for everything that has one of its own.
*
* Here rather than in [TranscriptRow.Single] because the two items that need something else are
* the two that know why: a tool call is named after its run, and a peer note is *sorted* by the
* turn it started rather than by where it arrived. Asking each item what it is called is also
* what stops the next such item being missed -- a `when` over concrete types in the row would
* have to gain a case, silently, and nothing says when it did not.
* the two that know why. Asking each item what it is called is also what stops the next such
* item being missed -- a `when` over concrete types would have to gain a case, silently.
*/
open val key: Any
get() = seq
@@ -59,17 +54,54 @@ sealed class TranscriptItem {
* What it buys is the split. [transcriptUnits] keeps the newest reply whole because a
* streaming reply's text changes per delta and splitting a changing text is a parse per
* delta -- but "newest" outlives the turn, so a session that ends on a long reply was
* drawing it as one item indefinitely, with every node of it alive. Measured on a Pixel 9
* Pro XL: one 34,996px reply on screen put the frame's draw phase at 13.8ms, 79% of it the
* framework's own bookkeeping, which grows with alive nodes.
* drawing it as one item indefinitely. Measured on a Pixel 9 Pro XL: one 34,996px reply on
* screen put the frame's draw phase at 13.8ms, 79% of it framework bookkeeping.
*
* Folded from the status event that ended the turn, rather than read off the screen's
* Folded from the status event that ended the turn rather than read off the screen's
* status, because rows only change through the held-events gate: the split changes the
* newest row's list identity, and doing that from a status flip while somebody is reading
* inside that reply would step the list under them. An event has to wait for the reader to
* be at the newest end; a screen state does not.
* inside that reply would step the list under them.
*/
val settled: Boolean = false,
/** 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()
data class ToolRun(
@@ -79,11 +111,10 @@ sealed class TranscriptItem {
* The run of adjacent calls this one belongs to, named once when the call is folded in and
* never recomputed.
*
* Carried rather than derived because a run can gain members at *either* end -- a new call
* arriving beside it, or a page of history arriving in front of it -- so no function of its
* current members is stable. It is the first call's id at the moment the run started, which
* is a name rather than a description: [joinPages] hands it to older calls that turn out to
* belong to the same run, instead of renaming the run they joined.
* Carried rather than derived because a run can gain members at *either* end, so no
* function of its current members is stable. It is the first call's id at the moment the
* run started, which is a name rather than a description: [joinPages] hands it to older
* calls that turn out to belong to the same run.
*/
val runId: String,
val tool: String,
@@ -94,19 +125,16 @@ sealed class TranscriptItem {
* The questions this call is waiting on, in the order they were asked.
*
* On the call's own row rather than beside it: an ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice and had to work
* out that it was one event. The backend says which call a question is about, so this is a
* fact rather than a match on the input.
* repeating the input verbatim, so the reader saw the same command twice. The backend says
* which call a question is about, so this is a fact rather than a match on the input.
*
* A list because AskUserQuestion asks up to four at once, and they are one decision to make
* -- a permission is the case of exactly one, not a different shape.
* A list because AskUserQuestion asks up to four at once, and a permission is the case of
* exactly one rather than a different shape.
*/
val asks: List<QuestionCard> = emptyList(),
/**
* Images this call's result carried, drawn under it.
*
* Beside it they had to be paired by position, and position is the thing a page boundary
* breaks -- a screenshot loaded on one page and its call on the next read as unrelated.
* Images this call's result carried, drawn under it. Beside it they had to be paired by
* position, and position is what a page boundary breaks.
*/
val images: List<String> = emptyList(),
) : TranscriptItem() {
@@ -134,9 +162,8 @@ sealed class TranscriptItem {
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
/**
* A message another agent sent this session.
*
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
* A message another agent sent this session. Its own row rather than a [UserMsg]: see
* [PeerMessageRow] for why the voice matters.
*/
data class PeerNote(
override val seq: Long,
@@ -145,11 +172,9 @@ sealed class TranscriptItem {
/**
* The seq of the event this note came in on, which is what makes it itself.
*
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began at
* so the note is drawn above the reply it caused. Two messages that arrive during one turn
* therefore share a seq -- and sharing an identity as well killed the app, because the
* transcript list refuses two items with one key. Two agents writing to a session mid-turn
* is an ordinary afternoon, not a corner.
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began
* at. Two messages that arrive during one turn therefore share a seq -- and sharing an
* identity as well killed the app, because the list refuses two items with one key.
*/
val arrived: Long = seq,
) : TranscriptItem() {
@@ -158,10 +183,32 @@ sealed class TranscriptItem {
}
/**
* A command the session ran on itself -- `/compact`, `/rename`.
* Where one turn ended and the next began with nothing said in between.
*
* Kept in the transcript rather than only shown while it waits, because it explains what
* follows: a conversation that suddenly has half the context, or a session with a new name.
* A 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
* than only shown while it waits, because it explains what follows: a conversation that
* suddenly has half the context, or a session with a new name.
*/
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
@@ -170,7 +217,6 @@ sealed class TranscriptItem {
/**
* A clear that happened: everything above it left the session's context and stayed on screen.
*
* Carries only its position, because that is all it means.
*/
data class ClearedNote(override val seq: Long) : TranscriptItem()
@@ -179,34 +225,43 @@ sealed class TranscriptItem {
* A compaction that happened, and what it recovered.
*
* In the transcript rather than only in the status line, because the status is gone the moment
* it finishes and this is the part worth keeping: it is the explanation for a gap in the
* conversation, and for a minute or two in which the session was busy with nothing to show.
* it finishes and this is the part worth keeping: the explanation for a gap in the
* conversation.
*
* The wire also says what triggered it, and this deliberately does not carry that: the row says
* the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would
* be a field nothing can read.
* The wire also says what triggered it, and this deliberately does not carry that -- the row
* says the two sizes and nothing else, so keeping the trigger would be a field nothing can
* read.
*/
data class CompactedNote(
override val seq: Long,
val preTokens: Long?,
val postTokens: Long?,
) : TranscriptItem()
/**
* The account ran out of quota, so the turn stopped here.
*
* A divider rather than an error: nothing failed, and what a reader scrolling back needs from
* it is the same thing a clear or a compaction gives them -- why the conversation stops at this
* line.
*
* [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the
* row has words for rather than a time it invents.
*/
data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem()
}
/**
* The run a call joins: the one it lands next to, or a new one named after itself.
*
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
* keeps whatever it was called when it started, however many calls arrive at either end of it
* afterwards.
* keeps whatever it was called when it started, however many calls arrive at either end afterwards.
*
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row
* rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is
* always visible, since a run of one is drawn as itself rather than as a group; and the calls
* around it fall into a group before it and a group after it, so where the reader was asked
* something is legible in the shape of the transcript without opening anything. It ends the run
* before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in
* the work, not a gap in the middle of one run.
* rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible,
* since a run of one is drawn as itself; and the calls around it fall into a group before it and a
* group after it, so where the reader was asked something is legible in the shape of the transcript
* without opening anything.
*/
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
@@ -219,34 +274,26 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
* boundary cut in two.
*
* Two things straddle a boundary: a tool call separated from its result, and a message separated
* from the rest of itself. Both were one thing before the transcript was cut into pages, and both
* have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn
* twice, arriving from the same cause.
* from the rest of itself. Both were one thing before the transcript was cut into pages.
*
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a
* nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than
* the miscount -- the extra row is at the join, so it also moves everything the reader was looking
* at.
* concatenating the two lists left *both*: the same call twice.
*
* Merged by the call's own id rather than by position, because position is exactly what a page
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
* newer on what an end knows (the output, and whether it finished), which is the only way round
* that loses nothing.
* boundary destroys. The older row wins on what a start knows and the newer on what an end knows,
* which is the only way round that loses nothing.
*
* The third thing is the *run*, and it is the one that used to be missed. Every page ends up here,
* but [adoptRun] only ran on the path where a split call had been found -- so the boundary that
* falls cleanly between two finished calls, which is most of them, went straight to concatenation
* and left the older page's calls under the run name they were folded with. On screen: one run of
* tool calls drawn as two groups, with the seam wherever the reader happened to have paged. The two
* early returns were an optimisation on a list the size of one page, and they were skipping work
* rather than saving it.
* falls cleanly between two finished calls, which is most of them, left the older page's calls
* under the run name they were folded with. On screen: one run of tool calls drawn as two groups,
* with the seam wherever the reader happened to have paged.
*/
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later)
val (older, newer) = healSplitThinking(healSplitMessage(earlier, later))
val startedEarlier =
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
val endedLater =
@@ -276,15 +323,14 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
/**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
*
* [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas
* accumulate into the message before them -- so two meeting at a join are always the two halves of
* one reply, and leaving them apart drew a single answer as two, with a paragraph break through the
* middle of a sentence.
* [foldEvent] never leaves an *unfinished* assistant message with another behind it inside one
* page, so an unsettled one at a join is always the far half of the reply the boundary cut, and
* leaving the two apart drew a single answer as two with a paragraph break through the middle of a
* sentence. Two settled replies meeting there are two turns and stay two.
*
* The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on
* screen, and renaming that is how the list loses its anchor. It grows by what the older half
* brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded,
* so the growth extends off the top of the screen, away from the row the list anchors to.
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
* loaded, so the growth extends off the top of the screen.
*/
private fun healSplitMessage(
earlier: List<TranscriptItem>,
@@ -295,6 +341,36 @@ private fun healSplitMessage(
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
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))
}
@@ -302,19 +378,18 @@ private fun healSplitMessage(
* Hands the older calls at the join the name of the run they are joining.
*
* The two pages were folded separately, so a run split by the boundary came back as two runs with
* two names. Naming the joined run after the *older* half would be the obvious way round and is the
* wrong one: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor and steps under them. So the arriving
* calls take the name of the ones already there, and nothing visible changes identity.
* two names. Naming the joined run after the *older* half would be the obvious way round and is
* wrong: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor.
*/
private fun adoptRun(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): List<TranscriptItem> {
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
// A question is in a run of its own on both sides of the join, the same as it would be had
// the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a
// group straight through the row the reader was asked something on.
// A question is in a run of its own on both sides of the join, the same as it would be had the
// two pages been folded as one. Without this the heal would merge a group straight through the
// row the reader was asked something on.
if (first.tool == ASK_USER_QUESTION) return earlier
val joining = first.runId
val tail = earlier.takeLastWhile {
@@ -329,10 +404,8 @@ private fun adoptRun(
* A peer message goes above the turn it started, not where it happened to arrive.
*
* The live Claude Code path cannot record it in place: the CLI says nothing about a peer message
* until the turn's `result`, so the event lands below the whole reply it caused -- the answer
* printed above the question. The server stamps it with where that turn began
* ([SessionEvent.PeerMessage.turnStart]) and the note takes that seq, so it sorts into the list
* where it belongs rather than being drawn out of order at the end.
* until the turn's `result`, so the event lands below the whole reply it caused. The server stamps
* it with where that turn began and the note takes that seq.
*
* Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and
* paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as
@@ -340,8 +413,7 @@ private fun adoptRun(
* seq belongs to a status change and a status draws no row -- true, and it answered the wrong
* question: what two notes stamped with the same turn collide with is each other.
*
* Without a stamp -- a message replayed out of a session file, which is already in the right place
* -- it stays where it arrived.
* Without a stamp -- a message replayed out of a session file -- it stays where it arrived.
*/
private fun placePeerNote(
items: List<TranscriptItem>,
@@ -360,15 +432,14 @@ private fun placePeerNote(
* The calls the note now sits in front of, renamed if they were sharing a run with the calls behind
* it.
*
* A run is named from what a call landed next to (see [runIdFor]), and nothing there knows about
* turns -- so a turn opening with a tool call, straight after one that ended with one, folds them
* into a single run. Left alone, [groupToolRuns] would flush at the note and hand both halves the
* same name: two rows with one key, which a keyed list cannot draw at all.
* A run is named from what a call landed next to, and nothing there knows about turns -- so a turn
* opening with a tool call, straight after one that ended with one, folds them into a single run.
* Left alone, [groupToolRuns] would flush at the note and hand both halves the same name: two rows
* with one key, which a keyed list cannot draw at all.
*
* The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right
* for the opposite reason. There the two halves were always one run and the newer was already on
* screen; here they were never one turn's work, and both halves change appearance at the same
* moment the note appears between them.
* for the opposite reason: there the two halves were always one run, here they were never one
* turn's work.
*/
private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<TranscriptItem> {
val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail
@@ -386,32 +457,81 @@ 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
// every frame, and the list would jump for the whole of a streamed answer.
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg) {
// A message growing again is not finished, whatever a status said in between.
items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
// Only into a reply that is still arriving. A settled one is a turn that ended, and
// text after it belongs to the next turn -- a separate message, drawn as its own row.
// Growing it instead ran two answers together with not even a space between them,
// 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 {
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
// A rule between the two, and only where they actually meet: anything that draws a
// 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 ->
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
runIdFor(items, event.id, event.tool),
event.tool,
event.input,
"",
done = false,
)
// A call id names one call for its whole lifetime. Codex can repeat the start while
// recovering an in-flight item; appending that replay made two rows with one key, and
// Compose aborts the entire LazyColumn when it encounters them. Ignoring the replay
// also repairs transcripts which already contain it when they are folded on reopen.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
items
} else {
items +
TranscriptItem.ToolRun(
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.ToolEnd ->
// Created when its start is not here, rather than dropped. A
// fold that only ever *updates* loses the whole call when the
// start fell outside the loaded window, and a tool call that
// renders as nothing is indistinguishable from one that never
// happened. The name is unknown from an end alone; loading the
// page before this one replaces the row with the real thing.
// Created when its start is not here, rather than dropped. A fold that only ever
// *updates* loses the whole call when the start fell outside the loaded window, and a
// tool call that renders as nothing is indistinguishable from one that never happened.
// Loading the page before this one replaces the row with the real thing.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
} else {
@@ -419,9 +539,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
TranscriptItem.ToolRun(
entry.seq,
event.id,
// The name is not known from an end alone, so a call that was an ask
// cannot be recognised as one here; loading the page before this
// replaces the row with the real thing, which is when it splits out.
// The name is not known from an end alone, so a call that was an ask cannot
// be recognised as one here; the page before this replaces the row.
runIdFor(items, event.id, "tool"),
"tool",
"",
@@ -440,9 +559,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
event.multiSelect,
emptyList(),
)
// A question with no tool behind it -- AskUserQuestion, or an ask
// whose call fell outside the loaded window -- is a card of its
// own, which is what every question was before this.
// A question with no tool behind it -- AskUserQuestion, or an ask whose call fell
// outside the loaded window -- is a card of its own.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
@@ -453,9 +571,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
}
}
is SessionEvent.Answered ->
// Resolved wherever it is drawn: a card of its own, or a tool
// row's ask. Missing the second left an Allow/Deny pair live on
// a question already answered from another device.
// Resolved wherever it is drawn: a card of its own, or a tool row's ask. Missing the
// second left an Allow/Deny pair live on a question already answered from another
// device.
items.map {
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
@@ -475,20 +593,25 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.CommandQueued -> items
// No row of its own: a message that is still waiting is drawn as a pending bubble below
// the transcript, and becomes an ordinary one where the session read it.
// No row of its own: a message that is still waiting is drawn as a pending bubble below the
// transcript, and becomes an ordinary one where the session read it.
is SessionEvent.MessageQueued -> items
// The bubble goes away and nothing takes its place: the message was never read, so there
// is nothing it belongs above.
// The bubble goes away and nothing takes its place: the message was never read, so there is
// nothing it belongs above.
is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items
// 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.AuthenticationRequired ->
items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of
// its own when there is not -- a person's own attachment belongs
// to no call, and neither does one whose call fell outside the
// loaded window.
// Under the call that produced it when there is one, and a row of its own when there is
// not -- a person's own attachment belongs to no call, and neither does one whose call
// fell outside the loaded window.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
@@ -497,26 +620,58 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
} else {
items + TranscriptItem.ImageItem(entry.seq, event.ref)
}
is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt)
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.UsageDelta -> items
// Said rather than skipped: a line the server could not read is a hole in the conversation,
// and one that draws nothing is a hole nothing on screen ever mentions.
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
}
/**
* A status saying the session stopped working is the moment its newest reply is finished.
*
* See [TranscriptItem.AssistantMsg.settled] for what the mark buys and why it is made here in the
* fold. Status changes are transcript events with seqs of their own, so a replayed session settles
* its replies the same way a live one does.
* See [TranscriptItem.AssistantMsg.settled]. Status changes are transcript events with seqs of
* their own, so a replayed session settles its replies the same way a live one does.
*/
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
if (sessionWorking(state)) return items
val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
if (last.settled) return items
return items.dropLast(1) + last.copy(settled = true)
// A block the turn ended in the middle of is over, however it ended. Left open it spins for
// the rest of the conversation, which says the machine is working when nothing is.
val ended = closeThinking(items) { it.copy(open = false) }
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(
@@ -533,8 +688,7 @@ private fun updateTool(
* The default dispatcher sizes itself to the machine, which is right for work somebody is waiting
* on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and
* taking every core for them leaves the thread that draws the frame queueing behind one -- measured
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile, which is the frame failing to
* *start* rather than taking too long once it had.
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile.
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
@@ -543,37 +697,37 @@ private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
* Parses the markdown among [rows], off whatever thread is drawing.
*
* Called where a page of transcript is folded rather than where a row is composed, which is the
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
* [ParsedReplies].
* whole point: the work happens seconds before the reader reaches the rows it was done for.
*
* What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
* message, every one of them whole, since every piece of a message is drawn from its one parse --
* because a string warmed under a key no row ever looks up is a miss that nothing reports; see
* [transcriptUnits], which is the flatten this has to agree with. It reads the same
* message -- because a string warmed under a key no row ever looks up is a miss that nothing
* reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same
* [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
* hand it back through here, while the whole loaded transcript crosses this on every page.
* hand it back through here.
*
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
* had thought about paid its whole parse in the frame it appeared in, with no counter saying which
* row it was.
* had thought about paid its whole parse in the frame it appeared in.
*/
suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
withContext(parsingThreads) {
val texts = rows.flatMap { row ->
when (row) {
is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
// A message from another agent is markdown too, and it is the longest thing
// in a transcript often enough that leaving it out was the whole of why one
// cost a fifth of a second to open: it was the only markdown in the app
// parsed on the thread that draws.
// A message from another agent is markdown too, and it is the longest thing in a
// transcript often enough that leaving it out was the whole of why one cost a fifth
// of a second to open.
is TranscriptItem.PeerNote -> listOf(row.text)
// A model's working is markdown too, and only once it is settled: an open block
// gains a delta at a time and is drawn by the incremental parse, so warming one
// would hold a parse of every prefix of it.
is TranscriptItem.ThinkingRow -> if (row.open) emptyList() else listOf(row.text)
else -> emptyList()
}
}
if (texts.isNotEmpty()) replies.warm(texts)
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's
// licence to draw these as blocks on the composing thread.
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's licence
// to draw these as blocks on the composing thread.
rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) }
}
}
@@ -1,6 +1,7 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -10,6 +11,9 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.text.selection.SelectionState
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.ui.Alignment
import androidx.compose.ui.Modifier
@@ -25,37 +29,32 @@ import androidx.compose.ui.unit.dp
* zero is the newest content and sits at the bottom, so a message arriving extends the end the
* viewport is pinned to and following it is not an effect -- and a page of older history lands at
* indices past everything visible, which moves nothing on screen. The keyboard is the same case
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge. A
* conversation shorter than the screen stacks from the bottom, hanging from the composer.
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge.
*
* The lazy list is also the whole of the windowing. Only what is near the viewport is composed and
* alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the
* property a plain column here had to approximate with retained ranges and stand-in spacers, each
* of which was a way to flicker. An item the framework composes is drawn the same frame it is
* placed, and an item off screen is not a node at all.
* of which was a way to flicker.
*
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
* reply, and its parse is already made by [warm] before the fold that introduces it -- so entering
* composition costs laying out one paragraph, not parsing a message.
* reply, and its parse is already made by [warm] before the fold that introduces it.
*
* The whole list sits in a [SelectionContainer], which is what makes every word in the transcript
* selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a
* transcript is one body of text to a reader, and a container per row would mean a selection could
* never cross from a reply into the tool output that follows it -- and would leave whatever was
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
* their tap handlers: selection is a long press, and the container passes an ordinary click through
* to the card under it.
* The whole list sits in a [SelectionContainer], which is what makes every word selectable by the
* platform's own press-and-hold. Here rather than at each place text is drawn: a transcript is one
* body of text to a reader, and a container per row would mean a selection could never cross from a
* reply into the tool output that follows it -- and would leave whatever was drawn without one
* silently unselectable. Rows keep their tap handlers: selection is a long press.
*
* [selection] is the container's own state, held by the caller rather than made here, because the
* rows have to be able to ask whether anything is selected before they act on a tap -- a tap whose
* job is to put a selection away is not also a tap on the card under it. See the caller's
* `expanding`.
* rows have to be able to ask whether anything is selected before they act on a tap.
*/
@Composable
fun TranscriptList(
units: List<TranscriptUnit>,
state: LazyListState,
moreHistory: Boolean,
historyError: String?,
onRetryHistory: () -> Unit,
selection: SelectionState,
modifier: Modifier = Modifier,
below: @Composable () -> Unit,
@@ -69,8 +68,7 @@ fun TranscriptList(
modifier =
// Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which
// half;
// see [drawAccounting]. Measure includes composing the items that scrolled in.
// half. Measure includes composing the items that scrolled in.
modifier
.layout { measurable, constraints ->
val started = System.nanoTime()
@@ -102,16 +100,29 @@ fun TranscriptList(
DebugStats.count("unit composed")
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
}
// Standing in for everything not fetched yet. Only here while there is more -- its
// appearance at the top edge is also roughly when the next page is asked for, so what
// it
// reports is a fetch in flight rather than an end reached.
// Standing in for everything not fetched yet. A failed fetch stays actionable here:
// when the loaded transcript is too short to scroll, this boundary is the only place
// the reader can be given another way to ask.
if (moreHistory) {
item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
CircularProgressIndicator(
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
)
if (historyError == null) {
CircularProgressIndicator(
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") }
}
}
}
}
}
@@ -9,18 +9,16 @@ import java.util.concurrent.atomic.AtomicReference
* rest.
*
* One seam rather than a cache the screen has to remember to consult. Everything it fetched before
* -- the opening window, the pages it scrolls back through, the span an anchor restore reaches for
* -- is asked of this, and everything the server sends is written into the cache on the way past,
* so the screen never learns which side answered. What it does learn, through [DebugStats], is how
* often each one did, which is how the saving is measured.
* is asked of this, and everything the server sends is written into the cache on the way past, so
* the screen never learns which side answered. What it does learn, through [DebugStats], is how
* often each one did.
*
* See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind here: the cache is never
* load-bearing. Every read has a network path beside it producing the same result, so a missing,
* evicted or damaged cache degrades to exactly what this screen did before it existed.
* See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind: the cache is never load-bearing.
* Every read has a network path beside it producing the same result.
*/
class TranscriptSource(
private val settings: ServerSettings,
private val sessionId: String,
private val address: TranscriptAddress,
val cache: SessionCache,
) {
private val stream = AtomicReference<EventStream?>(null)
@@ -51,24 +49,23 @@ class TranscriptSource(
*
* The screen must not resume a stream from a cached seq unless it is the same conversation. A
* transcript is append-only in ordinary use, but the file can be replaced or truncated -- a
* sandbox re-seeded with the same ids, a backup restored, a directory deleted and the session
* re-imported -- and the server's catch-up on such a file would hand this phone a continuation
* of a *different* conversation, spliced onto the cached one with no seam. That is the worst
* thing this feature can do, and it is caught with one request of a few hundred bytes, in the
* slot the opening page's request used to be in.
* sandbox re-seeded with the same ids, a backup restored, a session re-imported -- and the
* server's catch-up on such a file would hand this phone a continuation of a *different*
* conversation, spliced onto the cached one with no seam. Caught with one request of a few
* hundred bytes, in the slot the opening page's request used to be in.
*
* False purges the cache and means "open cold". A throw is the server not being askable, which
* is neither: the cached rows stay on screen, the failure goes on the stream banner, and the
* caller tries again on the stream's own reconnect schedule.
* is neither: the cached rows stay on screen and the caller tries again on the reconnect
* schedule.
*
* What this cannot see is a line changed in the middle of the file with the tail intact. That
* is what the Reload button in session settings is for, and its caption says so.
* is what the Reload button in session settings is for.
*/
suspend fun probe(): Boolean {
val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there.
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1)
val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
val matches =
answer.size == 1 &&
try {
@@ -86,7 +83,7 @@ class TranscriptSource(
*/
suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush()
return page.map { it.second }
@@ -100,8 +97,7 @@ class TranscriptSource(
* row count takes it -- a single reply is hundreds of lines -- so a page fetched after the
* reader has been away would run straight past the cached run and overlap it, and an
* overlapping page cannot be stored. Told where this phone's copy starts, the server stops
* there instead, the gap is closed with exactly the bytes it was wide, and the history behind
* it is served locally from then on.
* there instead.
*/
suspend fun page(before: Long, limit: Int, coalesce: Boolean): List<SeqEvent> {
cache.page(before, limit, rows = coalesce)?.let { lines ->
@@ -112,7 +108,7 @@ class TranscriptSource(
val page =
fetchTranscript(
settings,
sessionId,
address,
before = before,
limit = limit,
coalesce = coalesce,
@@ -135,7 +131,7 @@ class TranscriptSource(
* well lose.
*/
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, sessionId)
val opened = EventStream(settings, address)
stream.getAndSet(opened)?.close()
try {
opened.run(after, onOpen, onReset) { raw, entry ->
@@ -169,10 +165,8 @@ private const val OPENING_WINDOW = 80
*
* Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the
* server, which Android may delete under storage pressure without asking. Keyed by host and port
* because two servers can hold a session with the same id -- the sandbox and the real server, or a
* re-enrolment -- and a line from one shown against the other is the whole invariant broken. `v1`
* is the layout's version: a change to it bumps the segment, and a directory of another version is
* deleted the first time this is called.
* because two servers can hold a session with the same id, and a line from one shown against the
* other is the whole invariant broken. `v1` is the layout's version.
*/
fun cacheRoot(context: Context, settings: ServerSettings): File {
val transcripts = File(context.cacheDir, "transcripts")
@@ -12,8 +12,7 @@ import androidx.compose.ui.unit.dp
* at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be
* twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when
* the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst
* frame is bounded. This is the piece that was missing when a lazy list was last tried here; the
* block splitting existed only inside the row, where the list could not see it.
* frame is bounded. This is the piece that was missing when a lazy list was last tried here.
*
* Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit
* points back at its row. The list draws units; anchors and paging still speak seq.
@@ -27,10 +26,9 @@ sealed class TranscriptUnit {
abstract val seq: Long
/**
* This unit's position within its row, counted from the row's oldest end.
*
* What a saved scroll position carries besides the seq: a reply split into forty blocks needs
* more than "somewhere in this row" to put a reader back where they stopped.
* This unit's position within its row, counted from the row's oldest end. What a saved scroll
* position carries besides the seq: a reply split into forty blocks needs more than "somewhere
* in this row" to put a reader back where they stopped.
*/
abstract val ordinal: Int
@@ -66,15 +64,13 @@ sealed class TranscriptUnit {
*
* A peer message is the one row whose *opened* size is unbounded -- these are the longest
* things a transcript holds -- so it is flattened the same way a settled reply is, and for the
* same reason: as one item, every block of it is composed, measured, placed and kept alive
* while any part of it is on screen. Measured on the emulator, opening a 43KB one took the
* transcript's share of the draw phase from 0.81ms a frame to 3.85ms, and the framework's own
* per-frame bookkeeping -- which grows with how many nodes are *alive* -- from 0.39ms to
* 3.15ms.
* same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the
* draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from
* 0.39ms to 3.15ms.
*
* The card is drawn in pieces rather than given up: a filled Material card is elevation zero,
* so it has no shadow to break, and each piece paints the same fill with only the corners it
* owns. See [PeerHeadRow] and [PeerBlockRow].
* owns.
*/
data class PeerHead(
override val seq: Long,
@@ -84,8 +80,7 @@ sealed class TranscriptUnit {
) : TranscriptUnit() {
/**
* The note's own key, so opening and shutting does not change what the list is anchored on
* -- and so two notes stamped with one turn's seq are still two items. See
* [TranscriptItem.PeerNote].
* -- and so two notes stamped with one turn's seq are still two items.
*/
override val key: Any
get() = item.key
@@ -119,8 +114,7 @@ sealed class TranscriptUnit {
*
* A user message is plain text, so cutting it costs a scan rather than a parse -- but the
* reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand
* pixels of `Text` whose layout lands in the frame the row scrolls into. Measured as the
* `measure: the whole transcript ... 112.1ms worst` in an otherwise smooth report.
* pixels of `Text` whose layout lands in the frame the row scrolls into.
*/
data class UserChunk(
override val seq: Long,
@@ -136,6 +130,25 @@ sealed class TranscriptUnit {
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]. */
data class Memory(
override val seq: Long,
@@ -152,14 +165,11 @@ sealed class TranscriptUnit {
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start.
*
* Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is
* only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are,
* which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting.
* The reply still arriving -- the newest row, until the status event that ends its turn marks it
* [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and
* splitting it here would parse the whole message per delta on whichever thread is composing.
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
* live message a layer per piece. Once settled it splits like every other reply, which is what
* Every settled reply is cut into its pieces (via the caches on [replies] so a message is only ever
* cut once), and so is an *opened* peer message -- [openNotes] is which ones those are. A shut one
* is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text
* changes with every delta, and splitting it here would parse the whole message per delta on
* whichever thread is composing. Once settled it splits like every other reply, which is what
* bounds the newest row's cost after a session ends on a long one.
*
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
@@ -200,8 +210,8 @@ fun transcriptUnits(
}
}
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like
// the markdown splits so the scan too happens once per message, not once per fold.
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like the
// markdown splits so the scan happens once per message rather than once per fold.
val chunks = replies.chunksOf(item.text)
chunks.forEachIndexed { at, chunk ->
units +=
@@ -246,14 +256,27 @@ 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 {
units += TranscriptUnit.Whole(row, rowGap)
}
}
units.reverse()
reportDuplicateKeys(units)
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy"
// is this number growing, and it was invisible until it was written down.
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is
// this number growing, and it was invisible until it was written down.
DebugStats.record("units flattened", System.nanoTime() - started)
return units
}
@@ -261,9 +284,8 @@ fun transcriptUnits(
/**
* Whether this reply should be drawn as blocks: settled, or anywhere but the newest row.
*
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two
* questions are separate because they are answered by different things: this one by the fold, the
* other by whether [warm] has run for the text. [unwarmedReplies] is the gap between them.
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are
* answered by different things: this one by the fold, the other by whether [warm] has run.
*/
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
item.settled || index != lastIndex
@@ -272,8 +294,7 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex
* The replies among [rows] that should draw as blocks but whose parses are not made yet.
*
* Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold
* is the reply that just finished streaming -- nothing warms live deltas, so at the moment its turn
* ends its split would cost a whole-message parse on the composing thread. The session screen warms
* is the reply that just finished streaming -- nothing warms live deltas. The session screen warms
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses.
*/
@@ -291,6 +312,14 @@ 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
* 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
/**
@@ -386,6 +415,7 @@ private val TranscriptUnit?.kind: String
is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.ReplyFoot -> "reply footer"
is TranscriptUnit.Whole ->
when (val row = row) {
is TranscriptRow.Tools -> "tool group"
@@ -12,19 +12,15 @@ import androidx.compose.runtime.Composable
* on the main thread -- so it is not an error the screen can show, it closes the app. That is a
* disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on
* whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id
* recorded under two project directories, which is an ordinary state of a machine and not something
* the phone did.
* recorded under two project directories, which is an ordinary state of a machine.
*
* Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared
* the hazard and none of them could rule it out locally. Hence one function they all go through
* rather than a `distinctBy` remembered at each call site.
* the hazard and none could rule it out locally. Hence one function they all go through.
*
* Dropping the repeat is the right answer here because the key is the whole identity: two rows with
* one id are two rows every action would treat as the same thing, so there is nothing to show about
* the second that the first is not already showing. Where the duplicate means something -- the
* import list's did -- the fix belongs at the source, and this is only what stops a data problem
* from being a crash. It is counted so the render report says it happened rather than leaving a
* silently shorter list.
* Dropping the repeat is right here because the key is the whole identity: two rows with one id are
* two rows every action would treat as the same thing. Where the duplicate means something, the fix
* belongs at the source, and this is only what stops a data problem from being a crash. It is
* counted so the render report says it happened rather than leaving a silently shorter list.
*
* The transcript's own list is deliberately not on this: its keys are made here rather than
* received, and it is the one list where an extra pass over the items is measurable.
@@ -9,12 +9,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.unit.dp
@@ -27,17 +30,21 @@ import java.time.OffsetDateTime
* A dialog rather than a screen. Usage is something you check *against* what you were reading --
* "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for
* it took the session away to answer a question about the session. It also has no navigation of its
* own: there is nothing here to open, so the only thing its Back could ever have meant was "put
* this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog`
* handles that itself.
* own, so the only thing its Back could ever have meant was "put this away".
*/
@Composable
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the
// gaps between its title, its content and its buttons at sizes meant for a sentence of prose
// and a decision; this is a dense read-out, and those gaps left a band of empty dialog above
// Close that was taller than a bar. Everything else here is what AlertDialog would have
// drawn -- the same container colour, the same corner -- so nothing about it looks foreign.
fun UsageDialog(
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
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
// taller than a bar.
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
@@ -48,12 +55,6 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// Deliberately not subtitled with the provider this was opened from. These
// numbers belong to an account on a particular machine, reported by whichever
// paid service answered there -- naming the session's provider here made an
// echo session's screen read "echo" above a line reading "claude", which is a
// claim about echo that nothing measured. Each machine names itself and the
// service it came from, which is the true scope.
Text(
"Usage",
style = MaterialTheme.typography.headlineSmall,
@@ -69,12 +70,25 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
}
}
Spacer(Modifier.height(8.dp))
// Scrolls rather than being trimmed: a machine can report any number of windows
// and there can be any number of machines, and a dialog is the one place where
// 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
// running out of room is silent. `fill = false` so a short read-out keeps a short
// dialog instead of stretching to the window.
// dialog.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
UsageBody(feed.snapshots)
val state =
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)) {
Text("Close")
@@ -82,51 +96,67 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
}
}
}
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. */
@Composable
private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
private fun UsageBody(
state: LoadState<List<UsageSnapshot>>,
now: OffsetDateTime,
onSignIn: () -> Unit,
) {
Column {
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
if (current.value.isEmpty()) {
// Not an error and not a blank screen: no machine offers a paid service,
// so there is genuinely nothing to report and saying so is the answer.
// Not an error and not a blank screen: this provider has no paid quota, so
// there is genuinely nothing to report and saying so is the answer.
Text(
"No machine here runs anything with usage limits.",
"This session's provider has no usage limits.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// No card around each machine. A card is a step up the surface ladder, and
// inside a dialog -- itself a raised surface -- the step barely renders while
// costing 16dp of padding on every side. What separates one machine from the
// next is the line naming it, which is enough for a list this short.
// No card around each pool. A card is a step up the surface ladder, and inside
// a dialog -- itself a raised surface -- the step barely renders while costing
// 16dp on every side. What separates one pool from the next is the line naming
// it.
current.value.forEachIndexed { index, snapshot ->
if (index > 0) {
Spacer(Modifier.height(20.dp))
}
// Machine and service on one line: which account these numbers belong to
// is decided by both together, and stacked as a heading over a subtitle
// they read as a section of their own rather than as the label they are.
// Small and quiet, because the numbers below are what somebody opened
// this to see.
// Machine and service on one line: which account these numbers belong to is
// decided by both together, and stacked as a heading over a subtitle they
// read as a section of their own. Small and quiet, because the numbers
// below are what somebody opened this to see.
Text(
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
usageSectionTitle(snapshot),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SnapshotState(snapshot)
SnapshotState(snapshot, onSignIn)
snapshot.windows.forEachIndexed { windowIndex, window ->
// Between the bars, not after the last one: a trailing gap here is
// what put a band of empty dialog above the Close button.
// Between the bars, not after the last one: a trailing gap here is what
// put a band of empty dialog above the Close button.
if (windowIndex > 0) {
Spacer(Modifier.height(12.dp))
}
WindowBar(window)
WindowBar(window, now)
}
}
}
@@ -134,26 +164,49 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
}
}
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.
*
* The distinction the old single message could not draw. A machine nobody has logged in on is
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
* the interface nagging about a decision already made, and would dilute the marks that do mean
* something. Only the two faults are coloured as faults.
* the interface nagging about a decision already made. It still offers the direct sign-in action;
* unreachable and provider failures are the states coloured as faults.
*/
@Composable
private fun SnapshotState(snapshot: UsageSnapshot) {
private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
when (snapshot.state) {
"ok" -> {}
"notLoggedIn" ->
"notLoggedIn",
"loginRequired" -> {
Text(
"No Claude account on this machine.",
snapshot.detail ?: "No Claude account on this machine.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Reached but refused, versus never reached at all: different things to go and do,
// so they say different things rather than sharing one "unavailable".
TextButton(onClick = onSignIn) { Text("Sign in") }
}
"authenticating" ->
Text(
"Claude sign-in is in progress.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Reached but refused, versus never reached at all: different things to go and do, so they
// say different things rather than sharing one "unavailable".
"failed" ->
Text(
snapshot.detail ?: "Couldn't read the limits from this machine.",
@@ -170,7 +223,7 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
}
@Composable
private fun WindowBar(window: UsageWindow) {
private fun WindowBar(window: UsageWindow, now: OffsetDateTime) {
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
@@ -181,12 +234,8 @@ private fun WindowBar(window: UsageWindow) {
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(4.dp))
LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
color = quotaColor(window.percent),
modifier = Modifier.fillMaxWidth(),
)
resetLine(window)?.let {
UsageProgressIndicator(window, now, Modifier.fillMaxWidth())
resetLine(window, now)?.let {
Spacer(Modifier.height(2.dp))
Text(
it,
@@ -200,14 +249,13 @@ private fun WindowBar(window: UsageWindow) {
/**
* "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing.
*
* Null for a window that is not running, which is the case this row has always drawn as nothing and
* is right to: there is no end to report. What it used to get wrong is the other missing case, a
* timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared
* as an ISO string in the middle of a sentence written for a person. Both cases are named in
* Null for a window that is not running: there is no end to report. What this used to get wrong is
* the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse
* failure appeared as an ISO string in a sentence written for a person. Both are named in
* [WindowEnd], and the session bar words them the same way.
*/
private fun resetLine(window: UsageWindow): String? =
when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) {
private fun resetLine(window: UsageWindow, now: OffsetDateTime): String? =
when (val end = windowEnd(window.resetsAt, now)) {
WindowEnd.NotRunning -> null
WindowEnd.Unreadable -> "reset time unreadable"
is WindowEnd.Ends ->
Binary file not shown.
@@ -0,0 +1,28 @@
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."),
)
)
}
}
@@ -0,0 +1,33 @@
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,6 +338,21 @@ class HighlighterTest {
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
fun `every language the fence table knows has a scanner`() {
Language.entries.forEach { spansOf("x", it) }
@@ -0,0 +1,32 @@
package com.example.aiapp
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* What the transcript says where a session ran out of quota.
*
* The pair worth a test is the one that reads the same when it goes wrong: a reset time that
* arrived and one that never did. The second must not turn into a plausible-looking time, because a
* reader has no way of telling an invented one from a reported one.
*/
class LimitRowTest {
private val utc = ZoneId.of("UTC")
@Test
fun `a reported reset time is shown as a time`() {
// 2026-09-05T12:00:00Z. Asserted as a prefix and the clock reading rather than as the
// whole string: the platform's own short-time format is what this asks for, and it
// differs by JDK and locale down to which space character separates the meridiem.
val summary = limitSummary(1_788_609_600.0, utc)
assertTrue(summary.startsWith("Usage limit reached • resets "), summary)
assertTrue(summary.contains("12:00"), summary)
}
@Test
fun `a limit with no reset time says only what is known`() {
assertEquals("Usage limit reached", limitSummary(null, utc))
}
}
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more