Author SHA1 Message Date
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
109 changed files with 27582 additions and 3688 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
../../.claude/skills/ai-app-rigs
+328
View File
@@ -0,0 +1,328 @@
---
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.
**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.
+298 -223
View File
@@ -1,6 +1,6 @@
# ai-app # ai-app
A phone interface to AI coding sessions (Claude Code and llama.cpp), A phone interface to AI coding sessions (Codex, Claude Code and llama.cpp),
replacing the Claude app for daily use. Rust/Axum backend on the desktop, replacing the Claude app for daily use. Rust/Axum backend on the desktop,
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
between them. between them.
@@ -9,7 +9,15 @@ between them.
its rationale, and what was rejected. Read it before changing anything its rationale, and what was rejected. Read it before changing anything
structural, and update it in place when a decision changes rather than structural, and update it in place when a decision changes rather than
letting this file and the plan become two versions of the truth. This file is letting this file and the plan become two versions of the truth. This file is
the working notes layer: layout, commands, rigs, and things that have bitten. the working notes layer: layout, commands, and things that have bitten.
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and
ssh here, how importing behaves, and the measurements not worth re-taking.
They moved there on 2026-09-04 because they are 12 KB that only matter once
you are actually running one, and this file is sent with every request. Read
it before writing or running a benchmark, driving the UI from a script, or
touching the import screen.
The central design point, worth not undoing by accident: **a session is a The central design point, worth not undoing by accident: **a session is a
child process, translated into one common event model.** A new session type child process, translated into one common event model.** A new session type
@@ -26,10 +34,120 @@ Module-by-module intent is in PLAN.md's "Backend layout".
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc - `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
comment is the HTTP table and the surface's source of truth. comment is the HTTP table and the surface's source of truth.
**A machine's models are served by one shared `llama-server`** (2026-09-19,
`session/llama/router.rs`): started with no `-m`, which makes it a
**router** — it reads a preset file naming models and their flags, starts a
child server per model asked for, and routes by the `model` field in each
request. So a session has no process of its own, two sessions on one model
share one copy of it in memory, and a backend restart adopts one process
rather than one per session. Four things fall out of it and are easy to get
wrong again — a session records the router's pid in its own directory as
`process::Detail::Shared`, and `process::stop` refuses to signal a `Shared`
record, which is what keeps one session ending from unloading everybody's
model; **nothing stops a router on its own**, and the only thing that does
is the machine's provider view (`POST /machines/{id}/providers/{p}/stop`);
how a model is *loaded* is per model on its machine
(`ProviderConfig::model_settings`, `LLAMA_MODEL_PARAMS`) rather than per
session, and saving those settings rewrites the preset, which **unloads**
that model; and the preset is read back before every edit, because a router
adopted from an earlier run is serving sections this process has never seen
and rewriting without them unloads those.
**A llama.cpp session runs on its configured machine** (built
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
port the server binds *there* and the port that reaches it *here*, and
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
the command. Three things fell out of it and are easy to get wrong again —
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
because `llama-server` never reads the stdin whose closing ends a CLI and
the same kill left it loaded on the far machine; the model is looked for on
the machine that will serve it, so the spawn screen offers
`GET /machines/{id}/providers/{p}/models` rather than any list of this
backend's own; and the
readiness poll watches the process as well as the port, since a model that
will not load exits in a second and was being reported as "gave up after
300s". See PLAN.md's "Transport" and "llama-server management".
**A llama session has tools and runs the loop itself** (2026-09-19):
`--tools all` gives the router `llama-server`'s built-in set, which it also
*runs* (`GET /tools` for the definitions, `POST /tools` to call one), while
web search comes from an MCP server this backend connects to directly
(`session/llama/mcp.rs`, Exa preset in a discovered provider's
`mcpServers`). Driving the loop is what makes the permission gate ours:
`manual` asks before every call and remembers a tool you answer
"Always allow …" to, `bypassPermissions` never asks, and the allowances are
folded back out of the transcript. Which tools a *session* offers is a
filter applied to those definitions here, not a flag over there: one shared
server has one set, and the filter costs no reload (2,181 tokens of prompt
with all seven, 698 with none). Three more things fall out of it and are
easy to get wrong again — a model change **asks for another model** and
stops nothing, since the one being left may be another session's;
`parallel = 1` unless that model's settings say otherwise, and it is what
decides whether the MTP draft head is a 50% speed-up or a 33% loss; and
`spec-type = draft-mtp` is conditional on the file actually having a head,
because asking for one that is not there makes `llama-server` **exit**.
**A llama session's thinking is drawn** (2026-09-19): `reasoning_content`
becomes `Event::Thinking` deltas closed by an `Event::ThinkingDone` carrying
the span the *driver* measured, and the phone draws a card that spins while
the block is open and says "Thought for 12.4s" once it is not. The reasoning
is deliberately not part of the next prompt (`conversation` ignores it), and
`timings.predicted_per_second` and `timings.prompt_ms` off the same stream
become `UsageDelta`'s `tokensPerSecond` and `prefillMs`, which is the
"read 9.5s · 50.3 tok/s · 3:00 PM" under a finished reply — nothing else here
measures either, so every other driver sends `None`, and the clock is last so
that it does not move when a provider reports fewer of them.
**A turn's wait has two halves and says which** (2026-09-19):
`SessionStatus::Loading` is the model coming off disk and
`SessionStatus::Reading` is `llama-server` processing the prompt -- emitted
when the request goes out and cleared by the first thing the model says, of
any kind. Prefill is the expensive half here (~10s at 6k tokens, ~22s at
14k), and as `running` it looked exactly like thinking. The phone draws
both with the working spinner and its own words, "loading model" and
"reading prompt".
**Thinking effort is a param, and which levels exist is the model's answer**
(2026-09-19): the `thinking` param rides on the request as a chat-template
argument (`reasoning_effort`, or `enable_thinking: false` for `off`), so it
needs no restart -- and the driver asks the loaded server which levels its
template actually takes rather than trusting the offered list, because the
27B raises on `high` and answers to `xhigh`. A level it cannot take is
dropped and said in the transcript, naming the ones it can.
**Every one of those is a default rather than a constant** (2026-09-19):
`DriverKind::params` declares what a provider takes — key, label, shape,
and whether a change waits for a restart — and the phone renders whatever
arrives, on the spawn form and in the session settings dialog. Adding a
setting to a driver is one entry in that table and no app change. `tools`
is in there too, because the seven built-in definitions are ~1,500 tokens
of every prompt, which on a small window is the difference between a usable
session and one that overruns. `DriverKind::model_params` is the same table
for a provider's **models**, drawn in the machines tab's provider view —
the settings that decide how a model is loaded, which belong to the machine
because one loaded copy answers every session using it.
**A model is downloaded onto the machine that will serve it** (2026-09-19,
replacing the fetch this backend used to do onto its own disk, and the
Models tab that went with it). `models.rs` writes a script and a detached
`curl` runs it *there*; the state of a run is a file beside the partial
(`x.gguf.download`), so nothing about it is held in this process — it
survives the phone closing, this backend restarting and a second device
watching, and `kill -0` at each listing is what stops a machine that was
rebooted from leaving a download claiming to be running. The progress is
`wc -c` of the partial against the size HuggingFace published, the sha256
it publishes is what makes a resume safe, and a finished download is not a
state: it is a model, in the list beside the one still going.
Codex is one persistent `codex app-server --stdio` process per session; its
driver uses native turn steering and interruption, persists the protocol
state and thread id, and reads subscription limits through the same CLI
protocol.
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs `AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE slides the whole main screen over a session from the left (`MainPanel.kt`)
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and and what it has running beside the turn -- its background tasks over its
subagents (`BackgroundTasks.kt`, `SubagentPanel.kt`) -- from the right, both keeping
the session composed underneath; `MainScreen.kt` the root's three tabs
(sessions, import, machines); `Reorder.kt` the drag that moves a row of a
lazy list, used by the session list's handles -- **the order of that list is
the reader's own and nothing sorts it** (`POST /sessions/order`);
`MachineModels.kt` the models on one machine
and the downloads putting them there, drawn inside `ProviderScreen.kt` for a
provider that serves files off that machine's disk; `Api.kt`/`EventStream.kt`
the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
the Keystore-sealed token. the Keystore-sealed token.
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA - `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
@@ -39,7 +157,11 @@ Module-by-module intent is in PLAN.md's "Backend layout".
build without it, since it is a path dependency, which is what keeps the two build without it, since it is a path dependency, which is what keeps the two
projects version-locked to the commit this repo pins. What deliberately did projects version-locked to the commit this repo pins. What deliberately did
**not** move is the API surface and the config *schema*: routes, drivers, **not** move is the API surface and the config *schema*: routes, drivers,
sessions and setups are what makes this project itself. sessions and machines are what makes this project itself.
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
choices made there that are still awaiting review.
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and - `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`). `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it - `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
@@ -138,153 +260,6 @@ two icon buttons the same width without either being given one — and why
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
involved. That is how to verify the wg0-only posture. involved. That is how to verify the wg0-only posture.
## The rigs
Each exists because something was invisible without it.
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
and data directory, holding eight invented Claude Code transcripts and a
`claude` that is two lines of shell. **That isolation is the point**: the
import screen lists whatever is in `~/.claude/projects`, which in this VM is
real agent transcripts, so exercising *delete* against the ordinary server
deletes somebody's conversation and exercising *import* starts a real
`--resume` on the owner's account.
Its port and root derive from the checkout's name, so two checkouts'
sandboxes cannot reach each other, and its token is generated once into
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
the enrolment flow appended — so the emulator app is enrolled **once** (the
start banner prints the command) and stays enrolled. It shares the real TLS
certificates, because the installed APK pins that CA.
Driving verbs, so none of this is re-derived per session:
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
`./ui-sandbox.sh send SID text|@file`, and
`./ui-sandbox.sh api /path [curl args]`.
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
enrolment already there — for when the fixture under test was expensive to
build; plain `start` wipes them, which is right for the list-screen
fixtures and wrong for that.
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
fake CLI slow to start. Both exist because operations that finish in
milliseconds have states on the way that nothing can observe, and an
unobservable state is one where broken and working look identical.
It also builds a fixture tree at the sandbox home's `~/files` for the
explorer, holding the states otherwise only reachable by finding a real
machine in one: an empty directory, a name with a tab and one with an
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
symlink to a directory and a broken one, a source file per language, and
the three sizes the limits were measured against (`edit-32k.rs`,
`edit-128k.rs`, `big-source.rs`). Point a session at it with
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
The explorer's 409 is produced by editing the file on the machine
(`printf … > file`) between pressing the pencil and pressing save.
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
echo driver is the right rig for most things and the wrong one for anything
whose cost scales with what was actually written: a real reply is longer,
is real markdown, and carries tool calls whose input and output are
kilobytes. Two faults were invisible until a real transcript was loaded — a
page of history landing mid-fling threw the reader back to the newest end,
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
`-b` takes the biggest conversation on the machine rather than the newest,
which is what a scrolling test wants; `--stop` takes it down.
It copies the transcript into `/tmp` and gives the server a `HOME` of its
own, so the import can only see the copy — importing spawns `claude
--resume`, and against the real file that is a second CLI writing to a
conversation somebody may still be in. **A transcript never goes in this
repository**: they hold whatever was said, read and written in that
session, and `~/repos` is shared with the host besides.
- **A fake CLI exercises the process lifecycle without a token.** Point a
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
it holds the fifo open, records a real pid, writes nothing, and dies on a
signal. So adopt, stop, restart and start are all drivable without a real
`--resume` and without spending a turn on somebody's account. Reach for
this when what is under test is *whether a process is running*, and for
`debug-transcript.sh` when it is *what the transcript draws*.
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
the first session (or `-k` keeps the current screen), scrolls a fixed
gesture loop, and prints the app's render report — the same one the in-app
copy button produces, whose `on screen:` line names what the viewport was
holding. Compare two runs with the same gestures; the emulator's absolute
frame times transfer nothing, the report's accounting does. Run it either
side of any change under `Markdown*.kt`, `Transcript*.kt` or
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
that move first are the worst `record: one block`, the reparse mean while
streaming, and the draw phase's accounting line.
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
resets the report, sends FILE, waits for the transcript to stop growing,
and prints. Both of those are corrections to a first version that measured
nothing: a transcript parked further back never redraws while a reply
streams into it, and a session is idle at *both* ends of a turn, so polling
for idle answers before the turn has started.
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
framework, from `atrace` text output with no trace processor needed. It is
how the cost of a layout node per link was attributed to the framework
rather than guessed at.
### Driving the UI
**No script that drives this app's UI presses a coordinate.** Every control
is found by the name it already carries for assistive technology —
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
against the screen at the moment of the gesture and fails the whole run when
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
coordinate is a position measured once by hand, and anything that moves the
control makes the tap land on whatever now sits there — the bench then
reports a number that was never measured, which reads exactly like a result.
Both bench scripts pressed the render report at `tap 723 205` until that
button moved into the session settings dialog on 2026-09-03. The check that
none has crept back:
grep -n "tap [0-9]" app/*.sh
Swipes are still coordinates, deliberately: a gesture across a scrolling area
is a distance rather than a control.
**Two traps in the emulator bench loop**, each of which cost a run.
`adb shell pm clear` removes the enrolment and the notification permission
along with the saved anchors, so the next run measures a permission dialog —
re-enrol with the command `ui-sandbox.sh` prints, and
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
so the only way two builds start a scroll from the same place is a *fresh
session for each*.
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
`emu up` creates and boots the AVD named after this checkout — whatever `emu
name` prints, never a name typed out here, since this file is the same in
every clone. `run-android.sh` is that plus a build and an install. The `adb`
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
script from `emulator-tools` runs `emu check` before `installDebug`,
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
to every attached device; when it refuses, say which device you mean at the
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
### Testing llama.cpp and ssh here
The prebuilt CPU llama.cpp lives outside the repo at
`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its
own directory on `LD_LIBRARY_PATH`, so start the server as
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
broken driver — `llama-cli` produces the same from the file directly, which
is how to tell the two apart in a hurry.
There is no second machine, so **ssh this VM to itself**: generate a
throwaway key, append the public half to `~/.ssh/authorized_keys`, and
configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches
nothing real. Point a provider's `command` at something harmless like
`/bin/echo` rather than at `claude`: the transport is what is under test, the
process exiting immediately is the signal, and it costs no tokens. **Take the
key back out afterwards.** The remote login shell here is **fish**; the
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
both, but that is luck rather than design, and a shell that is neither is the
thing to suspect first if a remote spawn ever mangles an argument.
## Where things run (host vs this VM) ## Where things run (host vs this VM)
The machine itself — the two boxes, the shared `~/repos` mount, and why the The machine itself — the two boxes, the shared `~/repos` mount, and why the
@@ -293,7 +268,7 @@ means here:
- **`ai-server` belongs on the host in production.** That is where the LAN - **`ai-server` belongs on the host in production.** That is where the LAN
address the phone can reach is, and where WireGuard terminates. address the phone can reach is, and where WireGuard terminates.
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it `wg-machine-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
there with `sudo WG_ENDPOINT=<ddns name>`. there with `sudo WG_ENDPOINT=<ddns name>`.
- **The tunnel and the real phone can never terminate in the VM**, because - **The tunnel and the real phone can never terminate in the VM**, because
nothing outside can open a connection into it. Phone bring-up is host work. nothing outside can open a connection into it. Phone bring-up is host work.
@@ -326,47 +301,93 @@ day to day:
keep what a development server spawns. The flag decides only what **new** keep what a development server spawns. The flag decides only what **new**
sessions are marked as; what happens on the way out is decided by the sessions are marked as; what happens on the way out is decided by the
**mark**. **mark**.
- **A llama.cpp router is not cleaned up by any of that**, throwaway sessions
included: it belongs to the machine rather than to a session, and a
development server that has loaded a model leaves it loaded — gigabytes of
VRAM — after `pkill ai-server`. Stop it from the machines tab's provider
view, or `pkill -f "[l]lama-server"` when testing.
- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and - Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and
`stderr.log`. `stdout.log` is the driver's input, read from the byte offset `stderr.log`. `stdout.log` is the driver's input, read from the byte offset
in `process.json`; removing either by hand while the session is live loses in `process.json`; removing either by hand while the session is live loses
output or replays it. output or replays it.
## Importing ## Auto-resume
The import list reports each session's **size as well as its line count**, **A session switched to it sends itself a message once the account's usage
because the two disagree in the way that matters: these transcripts embed limit lifts** — off by default, per session, in the session settings dialog.
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB PLAN.md's "Auto-resume" is the design; day to day:
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 - **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time,
refuses it — see PLAN.md for the incident that made that a refusal rather asks `GET /usage`'s meter for that machine and provider, and only sends when
than a warning. it answers `ok` with nothing at 100%. Anything else — still spent, logged
out, unreachable — is a longer wait, and a still-spent window reschedules to
the reset time the *meter* now gives.
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
the same `limitReached` event a real driver does, and `/usage 100 5` sets
what the meter answers. They are deliberately separate: the two disagreeing
is the case the design exists for. `/usage 20` is the limit lifting.
- The wait is on the session in `config.ron` (`resume`), so it survives a
backend restart. A day after the limit was hit it gives up and says so in
the transcript.
**One Claude Code session id can name two files, and the listing offers it ## A session waiting on its own work
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** — Since 2026-09-06 a session whose turn ended with a **backgrounded subagent or
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the command still running** reports `waiting` rather than `idle` — its own status,
confirmation, and only where the driver keeps a record of its own drawn as the word "waiting" in `waitingColor` on both screens. `idle` means
(`keepsOwnTranscript`, which today means Claude Code). Off by default, "waiting for a person" and this means the opposite, so it also suppresses the
because leaving that copy is what makes an ordinary delete recoverable — and "finished" notification, which used to arrive at the one moment it was untrue.
the dialog's paragraph is rewritten when it is on rather than appended to, Two things fall out of it and are easy to get wrong again: the queue and the
since the sentence promising the conversation "should still be there to held-command boundary release on **either** end-of-turn status, so a message
import again" is exactly the one the switch makes false. The server deletes sent while a subagent runs is not held until the subagent finishes; and
the machine's copy *first*, so a machine it cannot reach leaves the session `sessionWorking("waiting")` is deliberately **false** — nothing is being
where it was instead of half-deleted. written, and the fold uses that same predicate to decide a reply is settled.
- **Nothing subagent-specific goes in the main agent's transcript** unless a
subagent sends it a real message that wakes it — which is the peer path, and
already has a row. A row per finished background task was tried and was a
screenful of dividers about work nobody was asking after, one of them a whole
shell command. A subagent's report is its own transcript's closing text and
is read in the subcard.
- **A backgrounded command has no subagent, so its report lands in the tool
card that launched it** — a `ToolUpdate` against the call's own id, replacing
the launch result that says it is still running. `/background [seconds]` in
an echo session is that shape end to end.
- **Two replies that meet are separated by a `TurnBreak`** — a hairline, no
words. The reply that follows a turn boundary is a **new** message: the fold
refuses to grow a settled reply, and without that the two ran together
mid-sentence. `./ui-sandbox.sh` plus `/subagent 3` or `/background 5` in an
echo session is the whole rig; the helpers stagger a second apart so each
reply is its own.
- **Claude's background-task level is authority; two edge sources are the
fallback.** Since Claude Code 2.1.261,
`background_tasks_changed { tasks: [...] }` replaces the live set and repairs
a missed ending edge. Its array size is also the measured `backgroundTasks`
count exposed on the session row and event stream; the phone draws a nonzero
count beside the status rather than deriving one from `waiting` or from the
subagent directory. **What those tasks are is `GET /sessions/{id}/background`**,
listed in the session's right panel above the subagents: runtime state, so it
is never persisted and `null` -- not an empty list -- is what a session with
no process answers. An `ambient` task is dropped from both the list and the
count, on the CLI's own instruction: a live-update watcher is not activity,
and counting one leaves a session `waiting` for ever. An adopted CLI is sent a repeated `initialize` to ask
for the current set. Reconcile only between turns or at a result boundary:
a foreground agent is legitimately absent from a background-only snapshot.
Older CLIs still need both edge sources: `open_tasks` knows about a
backgrounded command, while `Subagents::any_open` finds a subagent whose
`task_started` is behind an adopted stdout offset.
- **A usage limit a subagent hits reaches the session**, not just the
subagent's own transcript; auto-resume can only schedule against a session.
That is the case where the main agent is idle and a background Task is
still burning quota.
- **Codex's count is two id sets added together.** Open child thread ids come
from the subagent registry; live background command process ids come from
app-server's experimental `thread/backgroundTerminals/list`. The command set
is runtime state, refreshed at lifecycle edges and once a second while
nonempty. Never decrement it from an unmatched completion.
- **The status word and its colour are `sessionStatusWord` /
`sessionStatusColour`**, shared by the list and the session screen. They
were two `when`s, and the second one silently missed `waiting`.
## Shared appearance ## Shared appearance
@@ -379,10 +400,77 @@ where it was instead of half-deleted.
swallowed the drag along with the tap, so a list could not be scrolled swallowed the drag along with the tap, so a list could not be scrolled
while anything in it was busy. while anything in it was busy.
- **A rate-limit bar belongs to a session's provider, not to its machine.**
One machine offers echo, the Claude CLI and a local model at once and only
the CLI spends anything, so a session says which meter reports on it
(`usageProvider`, from `DriverKind::usage_provider`, which
`usage::providers_for` reads too so the two lists cannot disagree) and the
phone matches a snapshot on machine *and* provider. Nothing meters a llama
or echo session, and the phone draws **nothing** for one — not a zero, and
not "unknown". Nothing while the first fetch is out either: "checking"
under a session that turns out to meter nothing is a row the screen then
has to withdraw.
## Things that have bitten ## Things that have bitten
Project-specific only — a lesson that would bite any project on this machine - **A server started with no `--tools` answers 403 at `GET /tools`, not an
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead. empty list.** The route is off rather than empty, so reading that as a
failure made "no tools" — the one setting whose entire purpose is to have
none — a session that never started. The router is always given
`--tools all` now and the choice is a filter here, so this is a trap for
whoever next changes how the server is started.
- **`POST /models/load` answers 400 for a model that is already loaded**, and
that is the *ordinary* case once one server is shared: a second session
naming a model somebody else loaded. The router driver asks what is loaded
first and treats "it is there" as the answer whatever the request said.
- **Starting a process from a blocking thread needs the runtime.** Loading a
model is minutes of disk, so it runs on a `std::thread` — and tokio's
`Command::spawn` registers the child with the reactor, so calling it with no
runtime context panics. The panic kills only that thread: the session said
`loading` for ever and nothing appeared in the log. `Routers` holds a
`tokio::runtime::Handle` and enters it around the spawn.
- **A llama session reports `loading`, and a message sent into it queues.**
Before 2026-09-19 the session showed `running` from the moment the process
started, so a minute of reading a model off disk was indistinguishable from
a minute of thinking -- and anything sent in that window came back as an
error, because `llama-server` refuses everything until the model is in
memory. `SessionStatus::Loading` is the state. A driver that reports
`Loading` owes the holding as well as the word, and **the queue is where it
holds**: held inside the turn instead (until 2026-09-20) the message was
recorded as read on arrival, so the phone drew it as sent while nothing was
reading it, and the turn then folded it out of the transcript *and*
appended it, sending it to the model twice. `Shared::await_ready` is now
only for a turn whose model was changed under it.
- **A llama turn that says nothing said something that was thrown away.** Two
silent endings were found on 2026-09-20 and both looked, on the phone, like
a message that was sent and never answered: an `{"error": ...}` chunk
arriving mid-stream on an otherwise successful response (a GPU that ran out
of memory mid-decode), and a stream that simply stops without its `[DONE]`
(the model unloaded under the session). Neither is an ordinary end, and
`generate` now fails the turn for both -- a reply that stops early is not a
reply, and the transcript keeps whatever arrived before it.
- **A transcript outlives the enum.** Removing `Event::TaskNote` hours after
adding it made every transcript that had recorded one unreadable, so
`launch` failed for those sessions and `SessionManager::new` skipped them —
no status, nothing sendable, no new messages, for every live session that
had run a background task. **The set of kinds a transcript can hold only
ever grows**: a line may come from a newer server or from an older one that
wrote a kind since dropped, and one unfamiliar word must never be able to
end the file. `Indexed::parse_at` degrades a line it cannot read to
`Event::Unreadable { kind }`, keeping its seq — which is what everything
downstream is addressed by — and the phone draws it as a placeholder saying
which kind. Never delete a variant instead of retiring it; `Event::TaskNote`
is what retiring looks like, and the phone folds it to no row.
Project-specific only. A lesson that would bite any project on this machine
belongs in `~/.claude/MACHINE.md` or the `this-machine-*` skill for its
subject; one that would bite any project anywhere belongs in the
`code-lessons` skill, under the admission test at its end.
- **tracing caches callsite interest process-wide.** A test that hits a - **tracing caches callsite interest process-wide.** A test that hits a
`tracing::warn!` with no subscriber installed can poison the interest cache `tracing::warn!` with no subscriber installed can poison the interest cache
@@ -476,6 +564,19 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
the reader hit the end of what was loaded on every swipe and stood there the reader hit the end of what was loaded on every swipe and stood there
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
is actually on screen. is actually on screen.
- **A page landing while the history observer was fetching it must trigger its
own successor.** The observer once collected only `LazyListState.layoutInfo`;
while its collector was suspended in `loadOlderPage`, a compact page could
be composed and laid out without leaving another change to observe afterward.
Keying the effect on `oldestSeq` still missed the opening prefetch: that key
changed while `loadingHistory` was true, so the restarted effect declined to
overlap it and never noticed the flag returning to false. Codex exposes both
failures because a page full of calls collapses into one tool group: loading
stopped until expanding that group forced a layout. The observer now collects
the cursor, loading, restoring and failure state with the layout, so returning
to not-loading always rechecks the settled height. A failed page turns the
history boundary into a Try again control rather than retrying in a loop or
requiring another scroll.
- **Only `fetchTranscript` was off the main thread; the fold was not.** - **Only `fetchTranscript` was off the main thread; the fold was not.**
`foldEvent` returns a new list per event, so a page is that many copies of `foldEvent` returns a new list per event, so a page is that many copies of
a growing list — fine at 80 events and about 300,000 element copies at 800, a growing list — fine at 80 events and about 300,000 element copies at 800,
@@ -483,38 +584,12 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
shape: the `markdownIn` scan that decides *what* to parse ran before the shape: the `markdownIn` scan that decides *what* to parse ran before the
hop to `Dispatchers.Default`. The shape to watch for is a `withContext` hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
that wraps the *fetch* and leaves the work done with the result outside it. that wraps the *fetch* and leaves the work done with the result outside it.
- **A transcript snapshot cannot survive a suspension and then be assigned.**
## Measurements worth not re-taking `loadOlderPage` joined its page to `items`, suspended while `warm` parsed
markdown, and then assigned the joined snapshot. An SSE event arriving in
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU that gap appeared and vanished; reopening brought it back because the
emulator against a real imported transcript with the server at transcript and cache had it all along. Warm against a candidate if needed,
`--delay 120`. Settled and flinging fast, both into fresh history and back then join against the current `items` and assign without another suspension.
through rows already drawn: **5.25.9% janky frames, 99th percentile Also keep the page's original `oldestSeq`: a stream reset while the fetch or
2932ms, 02 slow UI-thread frames.** The stock Settings app on the same warm is suspended makes the page stale, and it must be discarded rather
device is 3.3% and 38ms, so this is at the platform floor. The number that than joined into the reset window.
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.
+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.
+36 -19
View File
@@ -14,7 +14,7 @@ AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
## What it is, in one paragraph ## What it is, in one paragraph
A machine's filesystem, seen from the phone through the backend. The explorer A machine's filesystem, seen from the phone through the backend. The explorer
belongs to a **setup** (a machine), not to a session: a session only says 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 where to start. Every operation — list, read, write, create — is one shell
script run through `Transport`, exactly the way the import listing and the script run through `Transport`, exactly the way the import listing and the
usage fetch already work, so the local and the ssh case are one usage fetch already work, so the local and the ssh case are one
@@ -25,16 +25,16 @@ message. The phone draws what came back.
### 1. Keyed on the machine, opened from the session ### 1. Keyed on the machine, opened from the session
Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem Routes live under `/machines/{id}/…`, beside `importable`, because a filesystem
is a property of a machine. The session screen's folder button opens the is a property of a machine. The session screen's folder button opens the
explorer with the session's setup and its `cwd`; a session with no `cwd` 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 opens at the machine's home, which the **machine** resolves (`cd` with no
argument and `pwd -P`), never a path the phone guessed. Nothing in the argument and `pwd -P`), never a path the phone guessed. Nothing in the
explorer knows what a session is, so a later entry point from the setups tab explorer knows what a session is, so a later entry point from the machines tab
is one more caller and no new code. is one more caller and no new code.
Rejected: routes under `/sessions/{id}/`. The session would be a detour to Rejected: routes under `/sessions/{id}/`. The session would be a detour to
find the setup, and "browse this machine" from anywhere else would need a find the machine, and "browse this machine" from anywhere else would need a
session to exist first. session to exist first.
### 2. One shell script per operation, over `Transport`, on both transports ### 2. One shell script per operation, over `Transport`, on both transports
@@ -68,7 +68,7 @@ Elsewhere the phone picks an **id** and the server resolves which file it
names, so an enrolled token cannot become "read me an arbitrary file". The names, so an enrolled token cannot become "read me an arbitrary file". The
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
Security section in these terms: the token already gates spawning a Security section in these terms: the token already gates spawning a
bypass-permissions agent in any directory on any machine a setup names, and 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 that agent can already read and write every file its user can. The explorer
is a shorter path to authority the token already holds, not new authority. is a shorter path to authority the token already holds, not new authority.
The import rule stands where it is, because there a path was unnecessary and The import rule stands where it is, because there a path was unnecessary and
@@ -82,13 +82,14 @@ writing are fixed scripts; the phone chooses only the path and the bytes.
Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
a relative path would be depends on something the reader cannot see. Every a relative path would be depends on something the reader cannot see. Every
listing answers with `pwd -P` of the directory it listed, so the phone listing answers with `pwd -P` of the directory it listed, so the phone
navigates on a resolved absolute path — the parent is a string operation on navigates on a resolved absolute path. The phone also resolves `~` through the
that, and a `~` the session was spawned with is shown as what it turned out same route, then shortens that directory and every path beneath it back to
to be. The phone never resolves `..` itself. 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 ### 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` (content, size, mtime, `GET /machines/{id}/file` answers with one of `text` (content, size, mtime,
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking `FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
at), or the machine's own error. at), or the machine's own error.
@@ -101,7 +102,7 @@ what it is.
### 6. A write is conditional on what the reader saw ### 6. A write is conditional on what the reader saw
`PUT /setups/{id}/file` carries the sha256 the read reported. The script `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; compares it against the file as it is now and exits distinctly if it differs;
the server answers **409**. Agents edit files while people read them; this is the server answers **409**. Agents edit files while people read them; this is
the common case, not the exotic one, and silently overwriting an agent's edit the common case, not the exotic one, and silently overwriting an agent's edit
@@ -122,9 +123,9 @@ precondition is fresh without a second read.
### 7. Create refuses to overwrite ### 7. Create refuses to overwrite
`POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a `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 name that exists fails with the shell's own message rather than truncating
somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same 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 property. The modal names one thing in the current directory and has a switch
for "directory"; a created file opens straight into edit mode, because an for "directory"; a created file opens straight into edit mode, because an
empty file is not something to look at. empty file is not something to look at.
@@ -208,17 +209,21 @@ absence the signal. Back with unsaved changes asks, and says the edits will
be lost. The explorer draws over the session, which deliberately has no be lost. The explorer draws over the session, which deliberately has no
`imePadding`, so the explorer's own box adds it. `imePadding`, so the explorer's own box adds it.
### 10. The explorer draws over the session, and back 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 `Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
`FilesScreen` is composed **on top of** the session in the same `Box`, and `FilesScreen` is composed **on top of** the session in the same `Box`, and
the session stays composed under it: its event stream keeps flowing, its the session stays composed under it: its event stream keeps flowing, its
scroll position and draft stay where they were, and returning from a file scroll position and draft stay where they were, and returning from a file
costs nothing. Back — the button and the platform gesture — clears `files` costs nothing. From an open file, both the header's back button and Android back
when set and goes to the list otherwise. Inside the explorer the same back return to its containing directory. From a directory, the header's back button
steps one level: editor → viewer (with the unsaved question) → listing → clears `files` and returns to the session. Android back instead walks toward the
parent directory, and only from the starting directory does it close. "Back session's project directory: upward to the common ancestor, then down one path
returns; it does not exit." 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 Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
leaf screen goes to Main today, and a session disposed and re-created on each leaf screen goes to Main today, and a session disposed and re-created on each
@@ -267,6 +272,18 @@ The speedometer went; the report is a "Copy render timings" row in
already are. **Moving it is where the no-coordinate-taps rule got enforced** already are. **Moving it is where the no-coordinate-taps rule got enforced**
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI". (Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
### 14. File links in a session open in the explorer
A markdown destination that is an absolute path or a local `file:` URI opens that document in the
session's explorer, on the session's machine. A trailing editor line and optional column are removed;
the viewer opens the file but does not yet scroll to a line. Web links, relative links and `file:`
URIs naming another host keep their ordinary external behaviour. The distinction is deliberately
narrow: a relative link might be a web reference, and the phone must not silently reinterpret it as
a path on another machine.
The markdown link handler is provided around the session rather than taught about machines. That
keeps the renderer reusable and makes the explorer's existing machine target the one navigation path.
## HTTP surface ## HTTP surface
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields` In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
+1018 -85
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 ## App — transcript
- [ ] Decide how running background tasks can be inspected. For now the session
status shows only the provider-reported count; command details stay in
their existing tool cards and must not become subagent cards.
- [ ] Messages received from other agents are inconsistent — sometimes they - [ ] Messages received from other agents are inconsistent — sometimes they
appear, sometimes they don't. **Needs a rig.** Read the code rather than appear, sometimes they don't. **Needs a rig.** Read the code rather than
measured: a live Claude session only learns of a peer message from the measured: a live Claude session only learns of a peer message from the
@@ -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 that would work today, for Claude sessions, and it is the option that was
not chosen. not chosen.
## A session the server could not load
- [ ] **A session whose transcript will not parse is skipped with nothing but a
log line, and from the phone it looks exactly like an idle unresponsive
one.** `SessionManager::new` catches a failing `launch` and logs
"couldn't relaunch session <id>", so the session has no pump and no
driver: no status, no history, nothing sendable. That is what the
`taskNote` incident (fd71d87) looked like from Bryan's phone, and why it
needed a report from him rather than being visible in the app.
`Event::Unreadable` removes the cause that time, but not the class — an
unreadable `process.json`, a provider edited away and an unreachable host
all reach the same place.
This is the "design the unknown state first" rule: a session the server
could not load is not a session with nothing to say, and only the phone
can show the difference. It needs a status the wire can carry for it —
the failure with its reason, reported on the session itself — rather than
the reader having to tell it apart from silence.
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,9 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -9,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
@@ -19,6 +22,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed import com.example.wgapplink.localNetworkAllowed
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -29,25 +33,39 @@ import kotlinx.coroutines.withContext
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them. * and the back button the only other way between them.
* *
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of * Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none
* them a step down from another -- and what is left here is only what genuinely is a step down: one * of them a step down from another -- and what is left here is only what genuinely is a step down:
* session, spawning one, and settings. * one session, spawning one, and settings.
*/ */
private sealed class Screen { private sealed class Screen {
data object Main : Screen() data object Main : Screen()
/** /**
* One session, with the file explorer 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 * Both are layers on this screen rather than screens of their own, so the session under them
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put, * stays composed: its event stream keeps flowing, its scroll position and draft stay put, and
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re- * coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every
* created on every return, refetching the transcript over the tunnel. * 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() data object Spawn : Screen()
/**
* One provider on one machine: its settings, and what its shared server is holding.
*
* A step down from the machines tab rather than a tab of its own, because it is about one
* machine rather than about the backend. Addressed by ids and names rather than by the
* [Provider] it was tapped from: what it shows is fetched, and a stale copy of a card would be
* a second version of the same truth.
*/
data class ProviderSettings(val machineId: String, val provider: String) : Screen()
data object Settings : Screen() data object Settings : Screen()
} }
@@ -178,7 +196,7 @@ fun AppRoot(
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is // deliberately does not: resizing a whole screen on every frame of the keyboard animation is
// the cost that made it lag, so it moves only its composer and transcript. // the cost that made it lag, so it moves only its composer and transcript.
when (val here = screen) { when (val here = screen) {
is Screen.Main -> Screen.Main ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
MainScreen( MainScreen(
settings = current, settings = current,
@@ -191,6 +209,9 @@ fun AppRoot(
screen = Screen.Session(imported) screen = Screen.Session(imported)
}, },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
) )
} }
is Screen.Session -> is Screen.Session ->
@@ -204,6 +225,66 @@ fun AppRoot(
// A Box so the explorer can be drawn *over* the session rather than instead of it. // A Box so the explorer can be drawn *over* the session rather than instead of it.
// No imePadding here, for the reason above -- the explorer adds its own. // No imePadding here, for the reason above -- the explorer adds its own.
Box { Box {
val fileLinkHandler = rememberFileLinkHandler { path ->
screen = here.copy(files = here.summary.filesTarget(path))
}
Box(
Modifier.then(
if (here.subagent != null || here.files != null)
Modifier.clearAndSetSemantics {}
else Modifier
)
) {
// How much background work the session has, from the one subscription
// to its events the screen below holds. Here because the panel and that
// screen both draw it, and must draw the same number.
var backgroundTasks by
remember(here.summary.id) {
mutableIntStateOf(here.summary.backgroundTasks)
}
// The two panels this session can be pulled aside for: its subagents
// from the right, and the whole main screen from the left. Both are here
// rather than screens of their own for the same reason the explorer is --
// the session under them stays composed. The main panel exists only
// inside a session, which is what makes it unswipeable until one has been
// opened.
SidePanels(
left = { active, close ->
MainPanel(
settings = current,
sessionId = here.summary.id,
active = active,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
onClose = close,
onGone = goToMain,
)
},
// The whole width: it stands in for the screen Back would have shown,
// rather than sitting over the session the way the subagents do.
leftFraction = 1f,
right = { active, close ->
SubagentPanel(
settings = current,
summary = here.summary,
active = active,
backgroundTasks = backgroundTasks,
onClose = close,
onOpenSubagent = { screen = here.copy(subagent = it) },
)
},
) {
CompositionLocalProvider(
LocalFileLinkHandler provides fileLinkHandler
) {
SessionScreen( SessionScreen(
settings = current, settings = current,
summary = here.summary, summary = here.summary,
@@ -211,7 +292,27 @@ fun AppRoot(
onFiles = { screen = here.copy(files = it) }, onFiles = { screen = here.copy(files = it) },
share = share, share = share,
onShareTaken = { share = null }, onShareTaken = { share = null },
onBackgroundTasks = { backgroundTasks = it },
) )
}
}
}
here.subagent?.let { subagent ->
BackHandler { screen = here.copy(subagent = null) }
Box(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
) {
key(subagent.id) {
SessionScreen(
settings = current,
summary = here.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = subagent,
)
}
}
}
// Its own back handler is registered after this screen's, so it is the one the // Its own back handler is registered after this screen's, so it is the one the
// platform asks first, and it steps back inside itself before closing. // platform asks first, and it steps back inside itself before closing.
here.files?.let { target -> here.files?.let { target ->
@@ -223,6 +324,15 @@ fun AppRoot(
} }
} }
} }
is Screen.ProviderSettings ->
Box(Modifier.imePadding()) {
ProviderScreen(
settings = current,
machineId = here.machineId,
provider = here.provider,
onBack = goToMain,
)
}
is Screen.Spawn -> is Screen.Spawn ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
SpawnScreen( SpawnScreen(
@@ -0,0 +1,155 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* The background work a session has going, above its subagents in the panel [SidePanels] slides
* over it from the right.
*
* Collapsed to its one-line count by default, the way everything else this app adds to a screen
* arrives: what a reader came to the panel for is the subagents, and a run of cards about work
* nobody asked after would push them off it. Expanding pushes them down instead of covering them,
* so the two are read together.
*
* Nothing is drawn at all when the count is zero -- including when the provider never said, which
* is the same absence the status row draws. A permanently visible "0 bg tasks" would be a line
* about nothing on every session that has never backgrounded anything, which is most of them.
*/
fun LazyListScope.backgroundTaskSection(
count: Int,
tasks: LoadState<List<BackgroundTaskSummary>?>,
expanded: Boolean,
onToggle: () -> Unit,
onRetry: () -> Unit,
) {
if (count == 0) return
item(key = "background-heading") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(onClick = onToggle),
) {
Text(
"${backgroundTaskLabel(count)} running",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
}
if (!expanded) return
when (tasks) {
is LoadState.Loading ->
item(key = "background-loading") {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
is LoadState.Error ->
item(key = "background-error") {
Column {
Text(
tasks.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = onRetry) { Text("Try again") }
}
}
// Null is the provider declining to say, which a session whose process has gone answers.
// Said in words: the count above came from somewhere, and an empty space under it would
// read as the tasks having finished rather than as nobody being left to ask.
is LoadState.Loaded ->
when (val rows = tasks.value) {
null ->
item(key = "background-unknown") {
Text(
"This session isn't saying what these are.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
else ->
uniqueItems(rows, key = { "background-${it.id}" }) { BackgroundTaskCard(it) }
}
}
}
/**
* One background task: what it is doing, and what kind of thing is doing it.
*
* Not something to open, unlike the subagent cards below it -- a task is a provider's runtime state
* and has no transcript of its own. A backgrounded agent that does is also in the subagent list,
* under its own name.
*/
@Composable
private fun BackgroundTaskCard(task: BackgroundTaskSummary) {
val kind = backgroundTaskKindLabel(task.kind)
OutlinedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
// The kind stands in as the title where the provider gave no description, rather than
// the id it named the task by: Codex reports a process number, which says nothing to
// the person reading and would look like a name somebody chose.
Text(
task.description ?: kind,
style = MaterialTheme.typography.titleSmall,
color =
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
else LocalContentColor.current,
)
if (task.description != null) {
Spacer(Modifier.height(2.dp))
Text(
kind,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* What a [BackgroundTaskSummary.kind] is called on screen.
*
* A kind this build has not heard of is named by what every one of them has in common rather than
* by the nearest word we do know, which would be this screen asserting something the server never
* said.
*/
private fun backgroundTaskKindLabel(kind: String) =
when (kind) {
"agent" -> "subagent"
"command" -> "background command"
"workflow" -> "workflow"
else -> "background task"
}
/**
* The heading over one group in the panel, so neither list is a run of cards with no name.
*
* The same band as the background section's own heading row above, rather than a gap chosen to look
* right here: what separates a heading from the cards above it is that both headings sit in a row
* of one height.
*/
@Composable
fun PanelSectionHeading(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 48.dp)) {
Text(text, style = MaterialTheme.typography.titleMedium)
}
}
@@ -1,10 +1,15 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -49,3 +54,49 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
* ends that tall would bow its sides. * ends that tall would bow its sides.
*/ */
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp) val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
/**
* A round button sized to the mark it draws.
*
* The composer's three actions -- attach, stop, send -- are single glyphs, and a pill's word-shaped
* padding around one glyph was width taken from the pickers beside it: with a long model name on
* the row, the permission mode ended up too small to hit. One diameter for all three, and it is the
* platform's minimum touch target rather than a button's shorter default height.
*
* [fill] null draws the outlined form, for the one of the three that does not act on the session.
*/
@Composable
fun CircleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
fill: Color? = null,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
val sized = modifier.size(CircleButtonSize)
if (fill == null) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = sized,
) {
content()
}
} else {
Button(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
colors = actionButtonColors(fill),
contentPadding = PaddingValues(0.dp),
modifier = sized,
) {
content()
}
}
}
/** How wide and tall one of those is; see [CircleButton]. */
val CircleButtonSize = 48.dp
@@ -160,6 +160,7 @@ private val FENCE_LANGUAGES: Map<String, Language> =
"shell" to Language.SHELL, "shell" to Language.SHELL,
"zsh" to Language.SHELL, "zsh" to Language.SHELL,
"console" to Language.SHELL, "console" to Language.SHELL,
"diff" to Language.DIFF,
"python" to Language.PYTHON, "python" to Language.PYTHON,
"py" to Language.PYTHON, "py" to Language.PYTHON,
"javascript" to Language.JAVASCRIPT, "javascript" to Language.JAVASCRIPT,
@@ -60,3 +60,23 @@ fun compactingLabel(seconds: Long?): String =
seconds < 60 -> "compacting ${seconds}s" seconds < 60 -> "compacting ${seconds}s"
else -> "compacting ${seconds / 60}m ${seconds % 60}s" else -> "compacting ${seconds / 60}m ${seconds % 60}s"
} }
/**
* How full the session is, as the status row says it.
*
* Three states, not two, and the third is the one that needed the words: a session whose occupancy
* is known and whose ceiling is not. That one keeps the bare figure, and a session with a ceiling
* gets both — the reader can see which they are looking at. What must not happen is a missing
* ceiling drawn as a number, or as a proportion of some assumed window, which would be this screen
* inventing the very fact it does not have.
*
* A llama.cpp session always has one, since the window is a flag its own server was started with. A
* coding CLI's is the vendor's business and neither control protocol states it, so those keep the
* bare figure they have always had.
*/
fun contextLabel(held: Long?, limit: Long?): String =
when {
held == null -> "context unknown"
limit == null -> "context ${tokens(held)}"
else -> "context ${tokens(held)} / ${tokens(limit)}"
}
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/** /**
* A line across the transcript saying what left the session's context. * A line across the transcript saying what left the session's context.
@@ -36,6 +40,28 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
} }
} }
/**
* The rule between two replies that met with nothing said in between -- see
* [TranscriptItem.TurnBreak].
*
* No words and no colour. Every other divider here reports something that happened and is worth
* finding by scanning; this one only says "these are two", and it appears once per turn that
* started without anybody typing. Saying more was a screenful of announcements about background
* work the reader was not asking after -- one of them a whole shell command, drawn as centred prose
* because the words came from somewhere that had no reason to keep them short.
*
* The outline colour is the scheme's one for structure rather than for meaning, which is what this
* is. Inset from both edges so it reads as a separator between two rows rather than as the top edge
* of the one under it.
*/
@Composable
fun TurnBreakRow(modifier: Modifier = Modifier) {
HorizontalDivider(
modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 6.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
/** /**
* The mark a clear leaves. * The mark a clear leaves.
* *
@@ -47,3 +73,40 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
fun ClearedRow(modifier: Modifier = Modifier) { fun ClearedRow(modifier: Modifier = Modifier) {
TranscriptDivider("Context cleared", clearedColor, modifier) TranscriptDivider("Context cleared", clearedColor, modifier)
} }
/**
* The mark running out of quota leaves.
*
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
* place: colour by consequence, so "there is nothing left to spend" is learned once.
*
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
* which is not a promise about when the session picks back up. A limit the session was told no
* reset time for says nothing about one -- that state has its own words rather than a plausible
* number.
*/
@Composable
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
}
/**
* What the row says. Split out so the wording is testable without a screen, since the two states it
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
* that reads the same when it goes wrong.
*
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
*/
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
val at = resetsAt?.let {
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone)
.format(Instant.ofEpochSecond(it.toLong()))
} catch (_: Exception) {
null
}
}
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
}
@@ -14,7 +14,7 @@ private const val RESET_EVENT = "reset"
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it * mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor. * saw as the new cursor.
*/ */
class EventStream(settings: ServerSettings, private val sessionId: String) { class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
private val stream = Sse(settings) private val stream = Sse(settings)
fun close() = stream.close() fun close() = stream.close()
@@ -35,7 +35,7 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
// one and the screen folds the other, and they have to be the same line. // one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit, onEvent: (raw: String, event: SeqEvent) -> Unit,
) { ) {
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data -> stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name. // A named frame carries no payload and a data frame has no name.
if (name == RESET_EVENT) onReset() if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data)) else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
@@ -61,6 +61,24 @@ sealed class SessionEvent {
data class AssistantText(val delta: String) : SessionEvent() data class AssistantText(val delta: String) : SessionEvent()
/** The durable value of the open assistant message, replacing its provisional deltas. */
data class AssistantTextFinal(val text: String) : SessionEvent()
/**
* The model's working, streamed the way its reply is: its own card, and deliberately not part
* of what the session said. Only a provider that actually streams its reasoning sends it.
*/
data class Thinking(val delta: String) : SessionEvent()
/**
* The thinking above this finished, having taken [ms].
*
* Measured by the driver, because only it can see when the model stopped: this app knows when
* an event *arrived*, and the last fragment of a block followed by a slow tool call looks
* exactly like thinking that went on that long.
*/
data class ThinkingDone(val ms: Long) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent() data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
data class ToolUpdate(val id: String, val output: String) : SessionEvent() data class ToolUpdate(val id: String, val output: String) : SessionEvent()
@@ -108,6 +126,26 @@ sealed class SessionEvent {
val turnStart: Long? = null, val turnStart: Long? = null,
) : SessionEvent() ) : SessionEvent()
/**
* A line in the transcript this build cannot read: a kind a newer server wrote, or one an older
* server wrote that has since been dropped.
*
* [kind] is the word the line called itself, so the row can say what is missing rather than
* that something is. The server makes these when reading; no driver sends one.
*/
data class Unreadable(val kind: String) : SessionEvent()
/**
* Retired on 2026-09-06, hours after it was added: a background task finishing, which turned
* out to be a screenful of notices about work nobody was asking after.
*
* Kept because a transcript is append-only -- the sessions that ran a background task in that
* window have these lines for ever. It draws no row, which is the whole reason it is still
* named here rather than left to fall through to [Unknown]: that would draw a placeholder per
* background task, which is the same wall the row was removed for.
*/
object RetiredTaskNote : SessionEvent()
/** /**
* A command the session was asked to run on itself and cannot run yet. Resolved by * A command the session was asked to run on itself and cannot run yet. Resolved by
* [CommandSent] with the same id; a command that ran straight away has only that one. * [CommandSent] with the same id; a command that ran straight away has only that one.
@@ -117,6 +155,9 @@ sealed class SessionEvent {
/** The same command, handed to the session. */ /** The same command, handed to the session. */
data class CommandSent(val id: String, val text: String) : SessionEvent() data class CommandSent(val id: String, val text: String) : SessionEvent()
/** Provider-reported number of background tasks alive now. */
data class BackgroundTasks(val count: Int) : SessionEvent()
data class Status(val state: String) : SessionEvent() data class Status(val state: String) : SessionEvent()
/** /**
@@ -135,7 +176,25 @@ sealed class SessionEvent {
* so adding turns up would report a figure the session stopped being true of. Null where the * so adding turns up would report a figure the session stopped being true of. Null where the
* dialect did not say, which leaves the context unmeasured rather than unchanged. * dialect did not say, which leaves the context unmeasured rather than unchanged.
*/ */
data class UsageDelta(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. * A compaction that finished, and how much context it recovered.
@@ -157,6 +216,20 @@ sealed class SessionEvent {
*/ */
data object Cleared : SessionEvent() data object Cleared : SessionEvent()
/**
* The session stopped because its account's usage limit was reached.
*
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
* limit reached|1788546972`, which is not readable by the person it is shown to.
*
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
*/
data class LimitReached(val resetsAt: Double?) : SessionEvent()
data class AuthenticationRequired(val message: String) : SessionEvent()
data class Error(val message: String) : SessionEvent() data class Error(val message: String) : SessionEvent()
/** /**
@@ -193,6 +266,9 @@ fun parseSeqEvent(json: String): SeqEvent {
) )
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id")) "messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta")) "assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"assistantTextFinal" -> SessionEvent.AssistantTextFinal(body.getString("text"))
"thinking" -> SessionEvent.Thinking(body.getString("delta"))
"thinkingDone" -> SessionEvent.ThinkingDone(body.getLong("ms"))
"toolStart" -> "toolStart" ->
SessionEvent.ToolStart( SessionEvent.ToolStart(
id = body.getString("id"), id = body.getString("id"),
@@ -240,19 +316,25 @@ fun parseSeqEvent(json: String): SeqEvent {
body.getString("text"), body.getString("text"),
if (body.has("turnStart")) body.getLong("turnStart") else null, if (body.has("turnStart")) body.getLong("turnStart") else null,
) )
"unreadable" -> SessionEvent.Unreadable(body.getString("kind"))
"taskNote" -> SessionEvent.RetiredTaskNote
"commandQueued" -> "commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text")) SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text")) "commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
"backgroundTasks" -> SessionEvent.BackgroundTasks(body.getInt("count"))
"status" -> SessionEvent.Status(body.getString("state")) "status" -> SessionEvent.Status(body.getString("state"))
"settings" -> "settings" ->
SessionEvent.Settings( SessionEvent.Settings(
model = body.optString("model").ifEmpty { null }, model = body.optString("model").ifEmpty { null },
permissionMode = body.optString("permissionMode").ifEmpty { null }, permissionMode = body.optString("permissionMode").ifEmpty { null },
) )
"contextWindow" -> SessionEvent.ContextWindow(body.getLong("tokens"))
"usageDelta" -> "usageDelta" ->
SessionEvent.UsageDelta( SessionEvent.UsageDelta(
body.getLong("tokens"), body.getLong("tokens"),
if (body.has("context")) body.getLong("context") else null, if (body.has("context")) body.getLong("context") else null,
if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null,
if (body.has("prefillMs")) body.getLong("prefillMs") else null,
) )
"compacted" -> "compacted" ->
SessionEvent.Compacted( SessionEvent.Compacted(
@@ -261,6 +343,12 @@ fun parseSeqEvent(json: String): SeqEvent {
trigger = body.optString("trigger").ifEmpty { null }, trigger = body.optString("trigger").ifEmpty { null },
) )
"cleared" -> SessionEvent.Cleared "cleared" -> SessionEvent.Cleared
"limitReached" ->
SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
)
"authenticationRequired" ->
SessionEvent.AuthenticationRequired(body.getString("message"))
"error" -> SessionEvent.Error(body.getString("message")) "error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type) else -> SessionEvent.Unknown(type)
} }
@@ -275,7 +363,21 @@ fun parseSeqEvent(json: String): SeqEvent {
* first time the server grows a state, and the drift would be a reply that never splits or one * first time the server grows a state, and the drift would be a reply that never splits or one
* split mid-stream. * split mid-stream.
*/ */
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting" 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 context after [event], given what it was before.
@@ -299,3 +401,18 @@ fun contextAfter(current: Long?, event: SessionEvent): Long? =
is SessionEvent.Cleared -> null is SessionEvent.Cleared -> null
else -> current else -> current
} }
/**
* The context window after [event], mirroring the server's `context_limit_after` for the same
* reason [contextAfter] mirrors its neighbour: the screen has to keep up between page loads.
*
* A window belongs to the process, so a session whose process has exited has none — left standing,
* a session restarted on a different model would draw its occupancy against the old model's
* ceiling.
*/
fun contextLimitAfter(current: Long?, event: SessionEvent): Long? =
when (event) {
is SessionEvent.ContextWindow -> event.tokens
is SessionEvent.Status -> if (event.state == "exited") null else current
else -> current
}
@@ -44,26 +44,46 @@ import kotlinx.coroutines.withContext
/** /**
* Which machine's files to show, and where to start. * Which machine's files to show, and where to start.
* *
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says * 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 setups tab -- one more * 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. * 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 target for this session's machine, optionally opened on [file]. */
fun SessionSummary.filesTarget(file: String? = null) =
FilesTarget(
machine = machine,
machineName = machineName,
start = cwd?.takeIf { it.isNotBlank() } ?: "~",
file = file,
)
/** Where the explorer is: in a directory, or in one file. */ /** Where the explorer is: in a directory, or in one file. */
private sealed class Spot(val path: String) { private sealed class Spot(val path: String) {
class Dir(path: String) : Spot(path) class Dir(path: String) : Spot(path)
class Doc(path: String) : 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. * The files on the machine a session runs on: browse them, read one, change one.
* *
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps * Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to * flowing and coming back from a file costs nothing. Both back controls return from a file to its
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from * directory. In a directory, Android back walks toward the session's project directory and closes
* where it opened. * 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; the refresh glyph is * Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* how one gets asked again on purpose, and creating something refetches the directory it was * how one gets asked again on purpose, and creating something refetches the directory it was
@@ -72,51 +92,82 @@ private sealed class Spot(val path: String) {
@Composable @Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope() 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>>() } val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) } var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below, // Edit mode and whether anything has been typed live here rather than in the pane below,
// because they are what back has to know about -- and back arrives from two places, the arrow // because both ways out have to ask before discarding it.
// and the platform's own gesture, which must mean the same thing.
var editing by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(false) }
var dirty by remember { mutableStateOf(false) } var dirty by remember { mutableStateOf(false) }
var askUnsaved by remember { mutableStateOf(false) } var unsavedDestination by remember { mutableStateOf<UnsavedDestination?>(null) }
val here = stack.last()
fun go(spot: Spot) { fun go(spot: Spot) {
editing = false editing = false
dirty = false dirty = false
stack = stack + spot here = spot
} }
fun back() { fun leave(destination: UnsavedDestination) {
when { if (editing && dirty) {
editing && dirty -> askUnsaved = true unsavedDestination = destination
editing -> editing = false } else if (destination == UnsavedDestination.Directory) {
stack.size > 1 -> { go((here as Spot.Doc).directory)
stack = stack.dropLast(1) } else {
editing = false onClose()
dirty = false
}
else -> onClose()
} }
} }
suspend fun load(path: String, again: Boolean) { 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] = LoadState.Loading
listings[path] = listings[path] =
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.setup, path)) LoadState.Loaded(fetchDir(settings, target.machine, path))
} }
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) 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( Box(
Modifier.fillMaxSize() Modifier.fillMaxSize()
@@ -129,14 +180,15 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
when (val spot = here) { when (val spot = here) {
is Spot.Dir -> { is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading val state = listings[spot.path] ?: LoadState.Loading
// The resolved path once there is one: a directory opened as `~` is called what // Navigate with the resolved path, but name anything under the machine's home
// it turned out to be, not what it was asked for. // the way somebody working there would write it.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
val shownAt = tildePath(at, homeDirectory)
FilesHeader( FilesHeader(
title = baseName(at), title = baseName(shownAt),
path = at, path = shownAt,
machine = target.setupName, machine = target.machineName,
onBack = ::back, onBack = { leave(UnsavedDestination.Session) },
) { ) {
GlyphButton( GlyphButton(
REFRESH_GLYPH, REFRESH_GLYPH,
@@ -152,7 +204,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
) )
} }
LaunchedEffect(spot.path) { load(spot.path, again = false) } LaunchedEffect(spot.path) { load(spot.path, again = false) }
DirectoryBody(state, onOpen = ::go) DirectoryBody(state, directory = spot, onOpen = ::go)
} }
is Spot.Doc -> is Spot.Doc ->
DocPane( DocPane(
@@ -161,22 +213,26 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
path = spot.path, path = spot.path,
name = baseName(spot.path), name = baseName(spot.path),
editing = editing, editing = editing,
homeDirectory = homeDirectory,
onEditing = { editing = it }, onEditing = { editing = it },
onDirty = { dirty = it }, onDirty = { dirty = it },
onBack = ::back, onBack = { leave(UnsavedDestination.Directory) },
) )
} }
} }
} }
if (askUnsaved) { unsavedDestination?.let { destination ->
UnsavedDialog( UnsavedDialog(
onDiscard = { onDiscard = {
askUnsaved = false unsavedDestination = null
editing = false if (destination == UnsavedDestination.Directory) {
dirty = false go((here as Spot.Doc).directory)
} else {
onClose()
}
}, },
onCancel = { askUnsaved = false }, onCancel = { unsavedDestination = null },
) )
} }
@@ -185,7 +241,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
if (creating && dir != null && listing != null) { if (creating && dir != null && listing != null) {
CreateDialog( CreateDialog(
settings = settings, settings = settings,
setup = target.setup, machine = target.machine,
directory = listing.path, directory = listing.path,
onDismiss = { creating = false }, onDismiss = { creating = false },
onCreated = { path, isDirectory -> onCreated = { path, isDirectory ->
@@ -196,7 +252,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
load(dir.path, again = true) load(dir.path, again = true)
// A new file has nothing to look at, so it opens where it can be filled in. // A new file has nothing to look at, so it opens where it can be filled in.
if (!isDirectory) { if (!isDirectory) {
go(Spot.Doc(path)) go(Spot.Doc(path, dir))
editing = true editing = true
} }
} }
@@ -248,7 +304,11 @@ private fun FilesHeader(
* looks like a right one. * looks like a right one.
*/ */
@Composable @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) { when (state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp)) is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
is LoadState.Error -> is LoadState.Error ->
@@ -289,7 +349,9 @@ private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot)
name = entry.name, name = entry.name,
trailing = trailingOf(entry), trailing = trailingOf(entry),
onClick = { onClick = {
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path)) onOpen(
if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path, directory)
)
}, },
) )
} }
@@ -357,6 +419,7 @@ private fun ColumnScope.DocPane(
path: String, path: String,
name: String, name: String,
editing: Boolean, editing: Boolean,
homeDirectory: String?,
onEditing: (Boolean) -> Unit, onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit, onDirty: (Boolean) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
@@ -381,7 +444,7 @@ private fun ColumnScope.DocPane(
state = LoadState.Loading state = LoadState.Loading
state = state =
try { 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) if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got) LoadState.Loaded(got)
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -404,7 +467,7 @@ private fun ColumnScope.DocPane(
try { try {
val written = val written =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
writeFile(settings, target.setup, path, draft.text, against) writeFile(settings, target.machine, path, draft.text, against)
} }
state = state =
LoadState.Loaded( LoadState.Loaded(
@@ -430,7 +493,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 (editing) {
if (saving) { if (saving) {
GlyphSpinner("Saving") GlyphSpinner("Saving")
@@ -527,7 +595,9 @@ private fun ColumnScope.DocPane(
scope.launch { scope.launch {
val fresh = val fresh =
try { try {
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) } withContext(Dispatchers.IO) {
fetchFile(settings, target.machine, path)
}
} catch (e: ApiException) { } catch (e: ApiException) {
saveError = e.message saveError = e.message
conflict = null conflict = null
@@ -571,7 +641,7 @@ private fun Note(text: String) {
@Composable @Composable
private fun CreateDialog( private fun CreateDialog(
settings: ServerSettings, settings: ServerSettings,
setup: String, machine: String,
directory: String, directory: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit, onCreated: (String, Boolean) -> Unit,
@@ -591,8 +661,8 @@ private fun CreateDialog(
scope.launch { scope.launch {
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, setup, path) if (isDirectory) createDir(settings, machine, path)
else createFile(settings, setup, path) else createFile(settings, machine, path)
} }
onCreated(path, isDirectory) onCreated(path, isDirectory)
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -678,6 +748,35 @@ internal fun parentOf(path: String): String? {
} }
} }
/**
* The next directory on the filesystem path from [current] to [destination], or null when there.
*
* Moving between two branches first walks upward to their common ancestor. Once [current] is that
* ancestor, the next press walks one segment down toward [destination]. Both paths are answers from
* the machine, so they are absolute and have no symlinks or `..` left to resolve here.
*/
internal fun nextDirectoryToward(current: String, destination: String): String? {
val here = current.trimEnd('/').ifEmpty { "/" }
val there = destination.trimEnd('/').ifEmpty { "/" }
if (here == there) return null
val beneathHere = if (here == "/") there.startsWith('/') else there.startsWith("$here/")
if (!beneathHere) return parentOf(here)
val next = there.removePrefix(here).trimStart('/').substringBefore('/')
return join(here, next)
}
/** A path as somebody on [home] writes it, leaving paths outside that home unchanged. */
internal fun tildePath(path: String, home: String?): String {
val at = path.trimEnd('/').ifEmpty { "/" }
val resolvedHome = home?.trimEnd('/')?.ifEmpty { "/" } ?: return at
return when {
at == resolvedHome -> "~"
resolvedHome != "/" && at.startsWith("$resolvedHome/") ->
"~${at.removePrefix(resolvedHome)}"
else -> at
}
}
/** What a path names: its last segment, with `/` naming itself. */ /** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String { internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/') val trimmed = path.trimEnd('/')
@@ -7,6 +7,8 @@ import androidx.compose.ui.text.buildAnnotatedString
/** What a span of code is, in the terms the palette has a colour for. */ /** What a span of code is, in the terms the palette has a colour for. */
enum class Kind { enum class Kind {
ADDITION,
DELETION,
KEYWORD, KEYWORD,
STRING, STRING,
LITERAL, LITERAL,
@@ -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. * one instance and lives with the rest of the palette.
*/ */
data class SyntaxPalette( data class SyntaxPalette(
val addition: Color,
val deletion: Color,
val keyword: Color, val keyword: Color,
val string: Color, val string: Color,
val literal: Color, val literal: Color,
@@ -34,6 +38,8 @@ data class SyntaxPalette(
) { ) {
fun of(kind: Kind): Color = fun of(kind: Kind): Color =
when (kind) { when (kind) {
Kind.ADDITION -> addition
Kind.DELETION -> deletion
Kind.KEYWORD -> keyword Kind.KEYWORD -> keyword
Kind.STRING -> string Kind.STRING -> string
Kind.LITERAL -> literal Kind.LITERAL -> literal
@@ -44,6 +50,26 @@ data class SyntaxPalette(
} }
} }
/** A unified diff is line-oriented: colour the changed lines and leave context untouched. */
fun scanDiff(code: String): List<Span> {
val spans = ArrayList<Span>()
var start = 0
while (start < code.length) {
val end = code.indexOf('\n', start).let { if (it == -1) code.length else it }
val kind =
when {
code.startsWith("+++", start) || code.startsWith("---", start) -> Kind.METADATA
code.startsWith("+", start) -> Kind.ADDITION
code.startsWith("-", start) -> Kind.DELETION
code.startsWith("@@", start) -> Kind.METADATA
else -> null
}
if (kind != null) spans.add(Span(start, end, kind))
start = if (end == code.length) end else end + 1
}
return spans
}
/** /**
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it. * [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
* *
@@ -82,8 +82,8 @@ private const val SETTLE_MS = 500L
@Composable @Composable
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) { fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) } var machines by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Setup?>(null) } var chosen by remember { mutableStateOf<Machine?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) } var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// What is happening to each row right now, as the word the row shows. A map keyed by id rather // What is happening to each row right now, as the word the row shows. A map keyed by id rather
@@ -100,9 +100,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
// themselves, not a flag, so the dialog can say what it is about. // themselves, not a flag, so the dialog can say what it is about.
var confirming by remember { mutableStateOf<List<Importable>?>(null) } var confirming by remember { mutableStateOf<List<Importable>?>(null) }
// Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty // Set from the selected Claude provider rather than repeated in the app.
// times. var permissionMode by remember { mutableStateOf("") }
var permissionMode by remember { mutableStateOf("auto") }
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it, // When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
// so a tap reading it needs no recomposition. // so a tap reading it needs no recomposition.
val movedAt = remember { mutableMapOf<String, Long>() } val movedAt = remember { mutableMapOf<String, Long>() }
@@ -114,9 +113,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Taken from the answer rather than kept across the load: the server is what knows what is * Taken from the answer rather than kept across the load: the server is what knows what is
* running, and this screen may be opening on work another phone started. * running, and this screen may be opening on work another phone started.
*/ */
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> = suspend fun fetchInto(machine: Machine): LoadState<List<Importable>> =
try { 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() running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap() rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
LoadState.Loaded(rows) LoadState.Loaded(rows)
@@ -124,10 +123,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
LoadState.Error(err.message ?: "Couldn't list sessions") LoadState.Error(err.message ?: "Couldn't list sessions")
} }
fun loadSessions(setup: Setup) { fun loadSessions(machine: Machine) {
sessions = LoadState.Loading sessions = LoadState.Loading
selected = emptySet() 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. */ /** Takes a row out of the list, once the machine no longer has it to offer. */
@@ -141,9 +140,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} }
LaunchedEffect(reloadToken) { LaunchedEffect(reloadToken) {
setups = machines =
try { try {
val found = withContext(Dispatchers.IO) { fetchSetups(settings) } val found = withContext(Dispatchers.IO) { fetchMachines(settings) }
found.firstOrNull()?.let { found.firstOrNull()?.let {
chosen = it chosen = it
loadSessions(it) loadSessions(it)
@@ -171,7 +170,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
selected = emptySet() selected = emptySet()
running = running + targets.associate { it.id to WAITING } running = running + targets.associate { it.id to WAITING }
rowErrors = rowErrors - targets.map { it.id }.toSet() rowErrors = rowErrors - targets.map { it.id }.toSet()
val setup = chosen val machine = chosen
val ids = targets.map { it.id } val ids = targets.map { it.id }
scope.launch { scope.launch {
// One request for the whole batch, not one per row. Sent row by row, a handover was // One request for the whole batch, not one per row. Sent row by row, a handover was
@@ -198,25 +197,28 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// The listing is the repair, because it carries the same state the events do. Only when // The listing is the repair, because it carries the same state the events do. Only when
// something still looks outstanding, so the ordinary case does not pay for a second // something still looks outstanding, so the ordinary case does not pay for a second
// listing, which is the most expensive call this screen makes. // listing, which is the most expensive call this screen makes.
if (setup != null && targets.any { running.containsKey(it.id) }) { if (machine != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are already // Quietly: no Loading, because blanking the list to report on rows that are already
// saying what is happening to them is the flicker this screen avoids everywhere // saying what is happening to them is the flicker this screen avoids everywhere
// else. // else.
sessions = fetchInto(setup) sessions = fetchInto(machine)
} }
} }
} }
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
LaunchedEffect(chosen?.id, provider?.name) {
permissionMode = provider?.defaultPermissionMode.orEmpty()
}
/** Continues [targets] in the background, leaving the screen where it is. */ /** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) { fun importAll(targets: List<Importable>) {
val setup = chosen ?: return val machine = chosen ?: return
val useProvider = provider ?: return val useProvider = provider ?: return
handOver(targets) { ids -> handOver(targets) { ids ->
startImport( startImport(
settings, settings,
setup = setup.id, machine = machine.id,
sessionIds = ids, sessionIds = ids,
provider = useProvider.name, provider = useProvider.name,
permissionMode = permissionMode, permissionMode = permissionMode,
@@ -232,7 +234,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* it, which is the case where waiting is the right thing anyway. * it, which is the case where waiting is the right thing anyway.
*/ */
fun importAndOpen(target: Importable) { fun importAndOpen(target: Importable) {
val setup = chosen ?: return val machine = chosen ?: return
val useProvider = provider ?: return val useProvider = provider ?: return
running = running + (target.id to IMPORTING) running = running + (target.id to IMPORTING)
rowErrors = rowErrors - target.id rowErrors = rowErrors - target.id
@@ -242,7 +244,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
spawnSession( spawnSession(
settings, settings,
setup = setup.id, machine = machine.id,
provider = useProvider.name, provider = useProvider.name,
// Nothing to say: the server titles it from the session it continues. // Nothing to say: the server titles it from the session it continues.
title = "", title = "",
@@ -270,10 +272,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null) java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
} }
LaunchedEffect(chosen?.id) { LaunchedEffect(chosen?.id) {
val setup = chosen?.id ?: return@LaunchedEffect val machine = chosen?.id ?: return@LaunchedEffect
try { try {
while (true) { while (true) {
val stream = ImportableStream(settings, setup) val stream = ImportableStream(settings, machine)
liveChanges.set(stream) liveChanges.set(stream)
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -341,24 +343,24 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
when (val loaded = setups) { when (val loaded = machines) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> { is LoadState.Loaded -> {
// Only worth choosing when there is a choice. // Only worth choosing when there is a choice.
if (loaded.value.size > 1) { if (loaded.value.size > 1) {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
loaded.value.forEach { setup -> loaded.value.forEach { machine ->
TextButton( TextButton(
onClick = { onClick = {
chosen = setup chosen = machine
loadSessions(setup) loadSessions(machine)
} }
) { ) {
Text( Text(
setup.name, machine.name,
color = color =
if (setup.id == chosen?.id) if (machine.id == chosen?.id)
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -375,7 +377,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} else { } else {
ChipGroup( ChipGroup(
label = "Permissions", label = "Permissions",
options = PERMISSION_MODES, options = provider?.permissionModes.orEmpty(),
selected = permissionMode, selected = permissionMode,
onSelect = { permissionMode = it }, onSelect = { permissionMode = it },
) )
@@ -439,9 +441,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
val setup = chosen ?: return@TextButton val machine = chosen ?: return@TextButton
confirming = null 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. // Coloured by consequence: this takes something away, wherever it appears.
@@ -12,13 +12,13 @@ package com.example.aiapp
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in * the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
* the next listing. * the next listing.
*/ */
class ImportableStream(settings: ServerSettings, private val setup: String) { class ImportableStream(settings: ServerSettings, private val machine: String) {
private val stream = Sse(settings) private val stream = Sse(settings)
fun close() = stream.close() fun close() = stream.close()
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) { fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
stream.run("/setups/$setup/importable/events", onOpen) { _, data -> stream.run("/machines/$machine/importable/events", onOpen) { _, data ->
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange) if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
} }
} }
@@ -16,6 +16,7 @@ enum class Language {
CPP, CPP,
CSHARP, CSHARP,
DART, DART,
DIFF,
FISH, FISH,
GO, GO,
JAVA, JAVA,
@@ -100,7 +101,7 @@ fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(la
// Lazy for the same reason [RULES] is, since it reads it. // Lazy for the same reason [RULES] is, since it reads it.
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy { private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } + RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
mapOf(Language.MARKDOWN to ::scanMarkdown) mapOf(Language.DIFF to ::scanDiff, Language.MARKDOWN to ::scanMarkdown)
} }
private val C_STYLE = BlockComment("/*", "*/", nests = false) private val C_STYLE = BlockComment("/*", "*/", nests = false)
@@ -0,0 +1,404 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The models on one machine, the downloads putting more there, and HuggingFace to find them in.
*
* This was a tab of its own, about the backend's own disk. It moved under the machine's llama.cpp
* provider on 2026-09-19, when a download came to run on the machine that will serve the file:
* there is no such thing as "the models", only this machine's, and the screen that decides how a
* model is loaded is the screen that should be able to fetch one.
*
* Everything here is the machine's state rather than this screen's. A download is a process on that
* machine with its progress written beside the partial file, so closing the app, locking the phone
* or restarting the backend does not touch it, and a second device watching sees the same numbers.
*/
@Stable
class MachineModelsState(
private val settings: ServerSettings,
private val machineId: String,
private val scope: CoroutineScope,
) {
var state by mutableStateOf<LoadState<Models>>(LoadState.Loading)
private set
var query by mutableStateOf("")
var results by mutableStateOf<LoadState<List<RemoteRepo>>?>(null)
private set
var openRepo by mutableStateOf<String?>(null)
private set
var repoFiles by mutableStateOf<LoadState<List<RemoteFile>>?>(null)
private set
/** What the last action said went wrong, shown above the list that action was taken in. */
var actionError by mutableStateOf<String?>(null)
private set
val models: Models?
get() = (state as? LoadState.Loaded)?.value
val downloads: List<Download>
get() = models?.downloads.orEmpty()
/** How big each downloaded model is, by key, for the cards the provider screen draws. */
val sizes: Map<String, Long>
get() = models?.local.orEmpty().associate { it.key to it.bytes }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchMachineModels(settings, machineId))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
/** Runs [action], says what it said if it failed, and asks the machine again either way. */
private fun act(action: suspend () -> Unit) {
scope.launch {
actionError =
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
reload()
}
}
fun search() {
openRepo = null
results = LoadState.Loading
scope.launch {
results =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(searchModels(settings, query)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
fun toggleRepo(repo: String) {
if (openRepo == repo) {
openRepo = null
return
}
openRepo = repo
repoFiles = LoadState.Loading
scope.launch {
repoFiles =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchRepoFiles(settings, machineId, repo))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
fun download(repo: String, file: String) = act {
startDownload(settings, machineId, repo, file)
}
fun cancel(key: String) = act { cancelDownload(settings, machineId, key) }
fun remove(key: String) = act { deleteModel(settings, machineId, key) }
}
/**
* One machine's models, asked for again while this screen is open.
*
* Polled rather than pushed: a download belongs to a machine, not to any session, so it has no
* event stream of its own. Faster while something is downloading, because that is the only thing
* here that changes by itself -- each ask is a round trip to that machine, and once a minute would
* be a progress bar that moved in jumps.
*
* [onLocalChange] fires when the set of models on the machine changes, which is how the screen
* around this learns that a download has become a model it must now draw settings for.
*
* [enabled] is false for a provider that holds no files of its own -- the Claude CLI names its
* models rather than storing them -- and then nothing is asked of the machine at all. Taken as a
* parameter rather than decided by the caller's `if`, so that this is composed unconditionally and
* keeps its search results across the moment the provider's kind arrives.
*/
@Composable
fun rememberMachineModels(
settings: ServerSettings,
machineId: String,
enabled: Boolean,
onLocalChange: () -> Unit,
): MachineModelsState {
val scope = rememberCoroutineScope()
val state = remember(settings, machineId) { MachineModelsState(settings, machineId, scope) }
LaunchedEffect(state, enabled) {
if (!enabled) return@LaunchedEffect
var known: List<String>? = null
while (true) {
state.reload()
val local = state.models?.local?.map { it.key }
if (local != null) {
if (known != null && known != local) onLocalChange()
known = local
}
delay(if (state.downloads.any { it.state == "running" }) 1500 else 5000)
}
}
return state
}
/** What is being fetched onto this machine, above the models it already has. */
fun LazyListScope.downloadCards(state: MachineModelsState) {
uniqueItems(state.downloads, key = { "download:" + it.key }) { download ->
DownloadCard(
download = download,
onCancel = { state.cancel(download.key) },
onResume = { state.download(download.repo, download.file) },
onRemove = { state.remove(download.key) },
)
}
}
/**
* Finding a model to fetch: a search, and what it found.
*
* Below the models this machine has rather than above them, because what is here is what the reader
* came for and getting another is the rarer errand.
*/
fun LazyListScope.modelSearch(state: MachineModelsState) {
item("search") {
Spacer(Modifier.height(16.dp))
Text("Get another model", style = MaterialTheme.typography.titleSmall)
Text(
"Downloaded onto this machine, which is where llama.cpp reads it from.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
val keyboard = LocalSoftwareKeyboardController.current
OutlinedTextField(
value = state.query,
onValueChange = { state.query = it },
label = { Text("Search HuggingFace") },
singleLine = true,
// The keyboard's own key searches, and puts itself away to show what it found. The
// button below this is under the keyboard while it is up, so without this the only
// way to press it is to dismiss the keyboard first -- which nothing on screen says.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions =
KeyboardActions(
onSearch = {
keyboard?.hide()
state.search()
}
),
modifier = Modifier.fillMaxWidth(),
)
TextButton(
enabled = state.query.isNotBlank(),
onClick = {
keyboard?.hide()
state.search()
},
) {
Text("Search")
}
}
when (val found = state.results) {
null -> {}
is LoadState.Loading -> item("searching") { CircularProgressIndicator() }
is LoadState.Error ->
item("search-failed") { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
uniqueItems(found.value, key = { "repo:" + it.id }) { repo ->
val open = state.openRepo == repo.id
RepoRow(repo, expanded = open) { state.toggleRepo(repo.id) }
// Inside the expanded repository's own item rather than as a section after the
// list: drawn after every card, a repository's files read as belonging to
// whichever card happened to be last.
if (open) {
when (val files = state.repoFiles) {
null -> {}
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error ->
Text(files.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
Column {
val busy = state.downloads.map { it.key }.toSet()
files.value.forEach { file ->
RepoFileRow(
file,
downloading = "${repo.id}/${file.path}" in busy,
) {
state.download(repo.id, file.path)
}
}
}
}
}
}
}
}
@Composable
private fun DownloadCard(
download: Download,
onCancel: () -> Unit,
onResume: () -> Unit,
onRemove: () -> Unit,
) {
val running = download.state == "running" || download.state == "verifying"
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(download.file, style = MaterialTheme.typography.titleSmall)
Text(
download.repo,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. HuggingFace sends no size when it
// was never told one, and a bar drawn from a guess is worse than one that admits it
// is counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say
// the opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
style = MaterialTheme.typography.bodySmall,
)
} else if (running) {
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
)
}
download.error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
download.state,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
if (running) {
TextButton(onClick = onCancel) { Text("Cancel") }
} else {
// A stopped download kept its partial file, so carrying on is the cheap
// answer and starting again is not the only one offered.
TextButton(onClick = onResume) { Text("Resume") }
TextButton(onClick = onRemove) { Text("Remove") }
}
}
}
}
}
@Composable
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
"${repo.downloads} downloads · ${repo.likes} likes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
}
}
}
@Composable
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(file.path, style = MaterialTheme.typography.bodyMedium)
Text(
gigabytes(file.bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
file.have -> "Downloaded"
downloading -> "Downloading"
else -> "Download"
}
)
}
}
}
fun gigabytes(bytes: Long): String =
if (bytes >= 1_000_000_000) {
"%.2f GB".format(bytes / 1_000_000_000.0)
} else {
"%.0f MB".format(bytes / 1_000_000.0)
}
@@ -1,5 +1,7 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -24,6 +27,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -37,19 +45,25 @@ import kotlinx.coroutines.withContext
* which is what keeps the enrolled token from being able to introduce commands. * which is what keeps the enrolled token from being able to introduce commands.
*/ */
@Composable @Composable
fun 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() 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 adding by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<Setup?>(null) } var renaming by remember { mutableStateOf<Machine?>(null) }
var confirmingDelete by remember { mutableStateOf<Setup?>(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 busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) } var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() { suspend fun reload() {
state = state =
try { try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) } withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) }
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
@@ -82,19 +96,19 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> is LoadState.Loaded ->
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
uniqueItems(current.value, key = { it.id }) { setup -> uniqueItems(current.value, key = { it.id }) { machine ->
SetupCard( MachineCard(
setup = setup, machine = machine,
onRename = { renaming = setup }, onRename = { renaming = machine },
onRediscover = { onRediscover = {
scope.launch { scope.launch {
busy = "Asking ${setup.name} what it has…" busy = "Asking ${machine.name} what it has…"
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
updateSetup( updateMachine(
settings, settings,
setup.id, machine.id,
rediscover = true, rediscover = true,
) )
} }
@@ -105,7 +119,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
reload() reload()
} }
}, },
onDelete = { confirmingDelete = setup }, onDelete = { confirmingDelete = machine },
onSignIn = { provider -> signingIn = machine to provider },
onProvider = { provider -> onProvider(machine.id, provider.name) },
) )
} }
} }
@@ -113,7 +129,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
} }
if (adding) { if (adding) {
AddSetupDialog( AddMachineDialog(
onDismiss = { adding = false }, onDismiss = { adding = false },
onAdd = { name, ssh -> onAdd = { name, ssh ->
adding = false adding = false
@@ -121,7 +137,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
busy = "Asking $name what it has…" busy = "Asking $name what it has…"
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) } withContext(Dispatchers.IO) { addMachine(settings, name, ssh) }
} }
.exceptionOrNull() .exceptionOrNull()
?.message ?.message
@@ -129,13 +145,13 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
reload() reload()
} }
}, },
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } }, onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } },
) )
} }
renaming?.let { setup -> renaming?.let { machine ->
RenameDialog( RenameDialog(
setup = setup, machine = machine,
onDismiss = { renaming = null }, onDismiss = { renaming = null },
onRename = { name -> onRename = { name ->
renaming = null renaming = null
@@ -143,7 +159,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
updateSetup(settings, setup.id, name = name) updateMachine(settings, machine.id, name = name)
} }
} }
.exceptionOrNull() .exceptionOrNull()
@@ -154,10 +170,10 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
) )
} }
confirmingDelete?.let { setup -> confirmingDelete?.let { machine ->
AlertDialog( AlertDialog(
onDismissRequest = { confirmingDelete = null }, onDismissRequest = { confirmingDelete = null },
title = { Text("Remove \"${setup.name}\"?") }, title = { Text("Remove \"${machine.name}\"?") },
text = { text = {
Text( Text(
"The machine is left alone -- this only stops this app offering it. " + "The machine is left alone -- this only stops this app offering it. " +
@@ -171,7 +187,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
scope.launch { scope.launch {
actionError = actionError =
runCatching { runCatching {
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) } withContext(Dispatchers.IO) {
deleteMachine(settings, machine.id)
}
} }
.exceptionOrNull() .exceptionOrNull()
?.message ?.message
@@ -187,34 +205,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 @Composable
private fun SetupCard( private fun MachineCard(
setup: Setup, machine: Machine,
onRename: () -> Unit, onRename: () -> Unit,
onRediscover: () -> Unit, onRediscover: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onSignIn: (Provider) -> Unit,
onProvider: (Provider) -> Unit,
) { ) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(setup.name, style = MaterialTheme.typography.titleSmall) Text(machine.name, style = MaterialTheme.typography.titleSmall)
Text( Text(
// Not "this machine": the seeded setup is *called* that, and the card read "this // Not "this machine": the seeded machine is *called* that, and the card read "this
// machine / this machine". // machine / this machine".
setup.address ?: "runs where the backend does", machine.address ?: "runs where the backend does",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
if (machine.providers.isEmpty()) {
Text( Text(
if (setup.providers.isEmpty()) { "Nothing found on it. Install something and rediscover.",
"Nothing found on it. Install something and rediscover."
} else {
setup.providers.joinToString(" · ") { it.name }
},
style = MaterialTheme.typography.bodySmall, 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) { Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onRename) { Text("Rename") } TextButton(onClick = onRename) { Text("Rename") }
TextButton(onClick = onRediscover) { Text("Rediscover") } TextButton(onClick = onRediscover) { Text("Rediscover") }
@@ -226,7 +309,7 @@ private fun SetupCard(
} }
@Composable @Composable
private fun AddSetupDialog( private fun AddMachineDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAdd: (String, SshDetails?) -> Unit, onAdd: (String, SshDetails?) -> Unit,
onTest: suspend (SshDetails?) -> List<Provider>, onTest: suspend (SshDetails?) -> List<Provider>,
@@ -236,6 +319,7 @@ private fun AddSetupDialog(
var address by remember { mutableStateOf("") } var address by remember { mutableStateOf("") }
var identity by remember { mutableStateOf("") } var identity by remember { mutableStateOf("") }
var attachmentsDir by remember { mutableStateOf("") } var attachmentsDir by remember { mutableStateOf("") }
var modelsDir by remember { mutableStateOf("") }
var tested by remember { mutableStateOf<String?>(null) } var tested by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) } var testing by remember { mutableStateOf(false) }
@@ -250,6 +334,7 @@ private fun AddSetupDialog(
port = typedPort, port = typedPort,
identityFile = identity.trim().ifEmpty { null }, identityFile = identity.trim().ifEmpty { null },
attachmentsDir = attachmentsDir.trim().ifEmpty { null }, attachmentsDir = attachmentsDir.trim().ifEmpty { null },
modelsDir = modelsDir.trim().ifEmpty { null },
) )
} }
@@ -293,6 +378,14 @@ private fun AddSetupDialog(
label = { Text("Folder for attached files (optional)") }, label = { Text("Folder for attached files (optional)") },
singleLine = true, singleLine = true,
) )
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
// the same place this backend keeps its own downloads, read on that machine.
OutlinedTextField(
value = modelsDir,
onValueChange = { modelsDir = it },
label = { Text("Folder for models (optional)") },
singleLine = true,
)
tested?.let { tested?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall) Text(it, style = MaterialTheme.typography.bodySmall)
@@ -339,8 +432,8 @@ private fun AddSetupDialog(
} }
@Composable @Composable
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) { private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(setup.name) } var name by remember { mutableStateOf(machine.name) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Rename") }, title = { Text("Rename") },
@@ -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,19 +26,23 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle 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 * These were screens reached by words in a row under the title, and the row was already full. Tabs
* full. Tabs say the same thing in less space and say one more thing besides: that these are places * say the same thing in less space and say one more thing besides: that these are places to be
* to be rather than errands to run. Sessions, the machine's importable history, the models on it * rather than errands to run. Sessions, the machine's importable history and the machines
* and the machines themselves are all *the same backend*, looked at four ways, and none is a step * themselves are all *the same backend*, looked at three ways, and none is a step down from
* down from another. Settings still is, which is why it stays a pushed screen with its own Back. * another. Settings still is, which is why it stays a pushed screen with its own Back.
*
* Models were a fourth tab until 2026-09-19. They are a machine's models now -- downloaded onto the
* machine that has to serve them -- so they live under that machine's llama.cpp provider, beside
* the settings deciding how each one is loaded. A tab about "the models" was a claim that there is
* one such set, and there is one per machine.
*/ */
private enum class MainTab(val label: String) { private enum class MainTab(val label: String) {
Sessions("Sessions"), Sessions("Sessions"),
Import("Import"), Import("Import"),
Models("Models"), Machines("Machines"),
Setups("Setups"),
} }
@Composable @Composable
@@ -51,6 +55,10 @@ fun MainScreen(
onSpawn: () -> Unit, onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit, onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
/** One machine's provider, opened from the machines tab. */
onProvider: (String, String) -> Unit,
/** A session the list has just deleted; see [SessionListScreen]. */
onDeleted: (String) -> Unit = {},
) { ) {
var tab by remember { mutableStateOf(MainTab.Sessions) } var tab by remember { mutableStateOf(MainTab.Sessions) }
var refreshToken by remember { mutableIntStateOf(0) } var refreshToken by remember { mutableIntStateOf(0) }
@@ -140,11 +148,12 @@ fun MainScreen(
reloadToken = token, reloadToken = token,
onOpen = onOpen, onOpen = onOpen,
onSpawn = onSpawn, onSpawn = onSpawn,
onDeleted = onDeleted,
) )
MainTab.Import -> MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported) ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token) MainTab.Machines ->
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token) MachinesScreen(settings = settings, reloadToken = token, onProvider = onProvider)
} }
} }
} }
@@ -32,6 +32,7 @@ import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.getUnescapedTextInNode
import com.mikepenz.markdown.utils.resolveImageAlt import com.mikepenz.markdown.utils.resolveImageAlt
import com.mikepenz.markdown.utils.resolveImageLink import com.mikepenz.markdown.utils.resolveImageLink
import java.net.URI
import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.ASTNode
@@ -89,6 +90,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
content.buildMarkdownAnnotatedString(node, style, settings) content.buildMarkdownAnnotatedString(node, style, settings)
} }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val fileLinkHandler = LocalFileLinkHandler.current
val onPlainTap = LocalMarkdownTap.current val onPlainTap = LocalMarkdownTap.current
val layout = remember { Ref<TextLayoutResult>() } val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour. // The renderer's own rule for a style that names no colour: the theme's text colour.
@@ -127,7 +129,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
when { when {
url != null -> { url != null -> {
up.consume() up.consume()
uriHandler.openUri(url) if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url)
} }
onPlainTap != null -> { onPlainTap != null -> {
up.consume() up.consume()
@@ -164,6 +166,55 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
*/ */
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null } val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* Opens a markdown destination inside the current session when it names a file on that session's
* machine. Null outside a session, where every link keeps its ordinary URI behaviour.
*/
val LocalFileLinkHandler = compositionLocalOf<((String) -> Boolean)?> { null }
/**
* A stable markdown link handler whose behaviour follows the latest [onFile]. Keeping its identity
* stable matters: every visible markdown paragraph reads it, and a session recomposes on every
* streamed event.
*/
@Composable
fun rememberFileLinkHandler(onFile: (String) -> Unit): (String) -> Boolean {
val latest = rememberUpdatedState(onFile)
return remember {
{ destination ->
val path = filePathOf(destination)
if (path == null) false
else {
latest.value(path)
true
}
}
}
}
/**
* The path named by a local-file markdown destination.
*
* Only absolute paths and local `file:` URIs are claimed. A relative destination might be a web
* link, and sending one to a machine's filesystem would silently give an ordinary link a different
* meaning. Editors commonly append a line and optional column; the current viewer opens the file
* itself, so those coordinates are removed here.
*/
internal fun filePathOf(destination: String): String? {
val uri = runCatching { URI(destination) }.getOrNull()
val path =
when {
destination.startsWith("/") && !destination.startsWith("//") ->
uri?.path ?: destination.substringBefore('#').substringBefore('?')
uri != null &&
uri.scheme.equals("file", ignoreCase = true) &&
(uri.host.isNullOrEmpty() || uri.host == "localhost") -> uri.path
else -> null
}
if (path.isNullOrEmpty() || !path.startsWith('/')) return null
return path.replace(Regex(":\\d+(?::\\d+)?$"), "")
}
/** /**
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the * [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text * behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
@@ -21,12 +21,25 @@ const val DEFAULT_MODEL = "default"
* one model rather than one model from another. Anything that does not look like that is returned * one model rather than one model from another. Anything that does not look like that is returned
* untouched. * untouched.
* *
* A llama.cpp session's model is not an identifier at all -- it is `owner/repo/file.gguf`, where
* the file was downloaded from -- so what is kept is the file, which is the part that tells two
* models apart, and the extension goes with the directories. The model's *own* name is better still
* and is not derivable here: it is inside the file, and only the server has ever opened it. Where a
* screen has the server's answer it should prefer it; this is the floor under every screen that
* does not.
*
* A display decision, not a correction: the full name is what the session reports. * A display decision, not a correction: the full name is what the session reports.
*/ */
fun modelLabel(model: String?): String { fun modelLabel(model: String?): String {
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
if (name.endsWith(GGUF)) {
return name.substringAfterLast('/').removeSuffix(GGUF)
}
return name.removePrefix("claude-").replace(DATED_SUFFIX, "") return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
} }
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */ /** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
private val DATED_SUFFIX = Regex("""-\d{8}$""") private val DATED_SUFFIX = Regex("""-\d{8}$""")
/** What every model a llama.cpp session can run is stored as. */
private const val GGUF = ".gguf"
@@ -1,374 +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. Keyed on the token as well, so the header's Refresh restarts the
// loop with a read now rather than leaving the reader watching for a second and a half.
LaunchedEffect(reloadToken) {
while (true) {
reload()
delay(1500)
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search HuggingFace") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
TextButton(
enabled = query.isNotBlank(),
onClick = {
openRepo = null
results = LoadState.Loading
scope.launch {
results =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(searchModels(settings, query))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
},
) {
Text("Search")
}
Spacer(Modifier.height(8.dp))
LazyColumn(Modifier.fillMaxSize()) {
when (val current = state) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(current.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded -> {
if (current.value.downloads.isNotEmpty()) {
item { SectionLabel("Downloading") }
uniqueItems(current.value.downloads, key = { it.key + it.run }) { download
->
DownloadCard(download) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
cancelDownload(settings, download.key)
}
}
.exceptionOrNull()
?.message
}
}
}
}
item { SectionLabel("On the backend") }
if (current.value.local.isEmpty()) {
item {
Text(
"None yet. Search above to find one.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
uniqueItems(current.value.local, key = { it.key }) { model ->
LocalModelCard(model) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
deleteModel(settings, model.key)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
results?.let { found ->
item { SectionLabel("HuggingFace") }
when (found) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
uniqueItems(found.value, key = { it.id }) { repo ->
val open = openRepo == repo.id
RepoRow(repo, expanded = open) {
if (open) {
openRepo = null
} else {
openRepo = repo.id
repoFiles = LoadState.Loading
scope.launch {
repoFiles =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(
fetchRepoFiles(settings, repo.id)
)
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
}
// Inside the expanded repository's own item rather than as a section
// after the list: drawn after every card, a repository's files read as
// belonging to whichever card happened to be last.
if (open) {
when (val files = repoFiles) {
null -> {}
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error ->
Text(files.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
Column {
val busy =
(state as? LoadState.Loaded)
?.value
?.downloads
.orEmpty()
.filter { it.state == "running" }
.map { it.key }
.toSet()
files.value.forEach { file ->
RepoFileRow(
file,
downloading = "${repo.id}/${file.path}" in busy,
) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
startDownload(
settings,
repo.id,
file.path,
)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
}
}
}
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Spacer(Modifier.height(12.dp))
Text(text, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun DownloadCard(download: Download, onCancel: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(download.file, style = MaterialTheme.typography.titleSmall)
Text(
download.repo,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. The server sends no total when it was
// never told one, and a bar drawn from a guess is worse than one that admits it is
// counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say the
// opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
style = MaterialTheme.typography.bodySmall,
)
} else {
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
)
}
download.error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row {
Text(
download.state,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
if (download.state == "running") {
TextButton(onClick = onCancel) { Text("Cancel") }
}
}
}
}
}
@Composable
private fun LocalModelCard(model: LocalModel, onDelete: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(model.file, style = MaterialTheme.typography.titleSmall)
Text(
"${model.repo} · ${gigabytes(model.bytes)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onDelete) { Text("Delete") }
}
}
}
@Composable
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
"${repo.downloads} downloads · ${repo.likes} likes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
}
}
}
@Composable
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(file.path, style = MaterialTheme.typography.bodyMedium)
Text(
gigabytes(file.bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
file.have -> "Downloaded"
downloading -> "Downloading"
else -> "Download"
}
)
}
}
}
private fun gigabytes(bytes: Long): String =
if (bytes >= 1_000_000_000) {
"%.2f GB".format(bytes / 1_000_000_000.0)
} else {
"%.0f MB".format(bytes / 1_000_000.0)
}
@@ -28,7 +28,7 @@ import androidx.compose.ui.unit.sp
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the * This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
* grounds that a system font may not have the glyph. That objection is about *relying* on a system * grounds that a system font may not have the glyph. That objection is about *relying* on a system
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here * font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
* is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols * 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 * font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
* the script did not subset is a glyph that silently isn't there. * the script did not subset is a glyph that silently isn't there.
* *
@@ -136,6 +136,16 @@ val EDIT_GLYPH = glyph(0xF03EB)
*/ */
val SAVE_GLYPH = glyph(0xF0193) val SAVE_GLYPH = glyph(0xF0193)
/**
* `md-menu` -- the burger: three stacked rules, drawn as the handle a row is dragged by.
*
* The mark for "take hold of this and move it" rather than for a menu, which is what it means on a
* row that has one: three rules look like the rows of a list, and the only thing here that draws
* them is a list being rearranged. Nothing else in this app opens a menu from a burger, so the two
* senses cannot be confused.
*/
val DRAG_GLYPH = glyph(0xF035C)
/** /**
* The size an icon draws at beside a line of text. * The size an icon draws at beside a line of text.
* *
@@ -165,7 +175,7 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
* own corners and beside a title it arrived at the first letter. And it is taller than any header's * own corners and beside a title it arrived at the first letter. And it is taller than any header's
* text, which is what lets the button fill a header row rather than sit in the middle of one. * text, which is what lets the button fill a header row rather than sit in the middle of one.
*/ */
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 * The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
@@ -34,6 +34,8 @@ import org.json.JSONObject
* gets a push from Google's servers, which would mean this backend talking to Google about * gets a push from Google's servers, which would mean this backend talking to Google about
* somebody's coding sessions, and the whole point of the tunnel is that it does not. * somebody's coding sessions, and the whole point of the tunnel is that it does not.
* *
* Every moment it hears about goes to the drawer; [show] decides what else is done with it.
*
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as * The cost Android charges is a notification of its own that cannot be dismissed. That is made as
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows * quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it * no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
@@ -138,10 +140,11 @@ class NotificationService : Service() {
// Nothing to tell somebody about the session they are reading. The transcript in front of // Nothing to tell somebody about the session they are reading. The transcript in front of
// them is already saying it. // them is already saying it.
if (isOnScreen(notification.sessionId)) return if (isOnScreen(notification.sessionId)) return
// The app is up: it says this itself, as a banner over whatever screen they are on. Never // The app is up, so it says this itself as a banner over whatever screen they are on --
// both -- one thing happened, and a drawer filling up behind an app that already showed you // which interrupts, where the drawer's row records: a banner lasts seconds and reaches only
// each one is a drawer nobody reads. // somebody already looking. Both go up, and the banner having done the interrupting is what
if (handOver(notification)) return // makes the row a silent one.
val banner = handOver(notification)
val manager = NotificationManagerCompat.from(this) val manager = NotificationManagerCompat.from(this)
// Two different noes, and both are answers rather than faults: the runtime permission // Two different noes, and both are answers rather than faults: the runtime permission
// refused, and notifications switched off for the app in Android's own settings. // refused, and notifications switched off for the app in Android's own settings.
@@ -172,6 +175,7 @@ class NotificationService : Service() {
.setAutoCancel(true) .setAutoCancel(true)
.setWhen((notification.at * 1000).toLong()) .setWhen((notification.at * 1000).toLong())
.setShowWhen(true) .setShowWhen(true)
.setSilent(banner)
.build() .build()
manager.notify(notification.sessionId, ALERT_ID, built) manager.notify(notification.sessionId, ALERT_ID, built)
} }
@@ -262,7 +266,8 @@ class NotificationService : Service() {
* Whether there is an app to reach is the subscriber count rather than a flag of its own: * Whether there is an app to reach is the subscriber count rather than a flag of its own:
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends * [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
* nor blocks the thread reading the stream, and the buffer is there so a handful of * nor blocks the thread reading the stream, and the buffer is there so a handful of
* sessions finishing together all land rather than the last one winning. * 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) private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
@@ -272,7 +277,11 @@ class NotificationService : Service() {
private fun handOver(notification: SessionNotification) = private fun handOver(notification: SessionNotification) =
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification) toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */ /**
* Somebody is looking at [sessionId]; nothing is posted about it until they stop, and
* whatever the drawer is already holding about it goes now rather than waiting to be swiped
* away. Opening the session *is* reading the notification, whichever way they got here.
*/
fun showing(context: Context, sessionId: String) { fun showing(context: Context, sessionId: String) {
onScreen = sessionId onScreen = sessionId
// Whatever was posted about it before is about to be read, so it has nothing left to // Whatever was posted about it before is about to be read, so it has nothing left to
@@ -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(),
)
}
}
}
@@ -0,0 +1,215 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Relays a provider CLI's headless browser login without ever owning its credentials.
*
* The URL and code live only in this composition. The CLI process on [machineId] remains the one
* OAuth client and the only writer of its credential file.
*/
@Composable
fun ProviderLoginDialog(
settings: ServerSettings,
machineId: String,
machineName: String,
provider: String,
onDismiss: () -> Unit,
onSignedIn: () -> Unit,
) {
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
var login by remember(machineId, provider) { mutableStateOf<ProviderLogin?>(null) }
var code by remember(machineId, provider) { mutableStateOf("") }
var error by remember(machineId, provider) { mutableStateOf<String?>(null) }
var retry by remember(machineId, provider) { mutableIntStateOf(0) }
suspend fun follow(initial: ProviderLogin): ProviderLogin {
var current = initial
val wasSubmitting = initial.state == "submitting"
while (current.state == "starting" || current.state == "submitting") {
delay(400)
current =
withContext(Dispatchers.IO) {
fetchProviderLogin(
settings,
machineId,
provider,
current.attempt,
)
}
login = current
}
if (wasSubmitting && current.state == "waitingForCode" && current.detail == null) {
current =
current.copy(
detail = "That code was not accepted. Copy the complete code and try again."
)
login = current
}
return current
}
LaunchedEffect(machineId, provider, retry) {
error = null
code = ""
login = null
try {
val started =
withContext(Dispatchers.IO) { startProviderLogin(settings, machineId, provider) }
login = started
if (follow(started).state == "succeeded") {
onSignedIn()
}
} catch (e: ApiException) {
error = e.message
}
}
fun dismiss() {
login
?.takeUnless { it.state in setOf("succeeded", "failed", "cancelled") }
?.let {
scope.launch(Dispatchers.IO) {
runCatching { cancelProviderLogin(settings, machineId, provider, it.attempt) }
}
}
onDismiss()
}
AlertDialog(
onDismissRequest = ::dismiss,
title = { Text("Sign in to Claude") },
text = {
Column {
Text(
"Claude will sign in on $machineName. Open the authorization page, then " +
"paste the code it gives you here."
)
Spacer(Modifier.height(12.dp))
when (val current = login) {
null ->
if (error == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Text("Starting sign-in…")
}
}
else ->
when (current.state) {
"starting",
"submitting" ->
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Text(
if (current.state == "submitting") "Checking code…"
else "Starting sign-in…"
)
}
"waitingForCode" -> {
TextButton(
onClick = {
runCatching {
current.authorizationUrl?.let(uriHandler::openUri)
}
.onFailure {
error = "Couldn't open the authorization page."
}
},
enabled = current.authorizationUrl != null,
) {
Text("Open authorization page")
}
OutlinedTextField(
value = code,
onValueChange = { code = it },
label = { Text("Authorization code") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
current.detail?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
}
"succeeded" -> Text("Signed in on $machineName.")
"cancelled" -> Text("Sign-in was cancelled.")
else ->
Text(
current.detail ?: "Sign-in failed.",
color = MaterialTheme.colorScheme.error,
)
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
val current = login
when {
current?.state == "waitingForCode" ->
TextButton(
onClick = {
scope.launch {
error = null
try {
val submitted =
withContext(Dispatchers.IO) {
submitProviderLoginCode(
settings,
machineId,
provider,
current.attempt,
code,
)
}
login = submitted
if (follow(submitted).state == "succeeded") {
onSignedIn()
}
} catch (e: ApiException) {
error = e.message
}
}
},
enabled = code.isNotBlank(),
) {
Text("Continue")
}
error != null || current?.state == "failed" || current?.state == "cancelled" ->
TextButton(onClick = { retry++ }) { Text("Try again") }
current?.state == "succeeded" -> TextButton(onClick = onDismiss) { Text("Done") }
}
},
dismissButton = {
if (login?.state != "succeeded") {
TextButton(onClick = ::dismiss) { Text("Cancel") }
}
},
)
}
@@ -0,0 +1,117 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* The controls for whatever settings a provider says it takes.
*
* One composable for both screens that offer them — the spawn form and the session settings dialog
* — and for every provider, because the server declares the list (see `DriverKind::params`) rather
* than this file knowing it. A driver that grows a setting gets a control here with no change to
* the app, which is the whole point: the values that suit one machine ship as defaults, and every
* one of them stays reachable from a phone.
*
* [values] is the whole map and [onChange] hands back the whole map. A key absent from it means the
* setting is unset, which is what every [ParamSpec.unset] describes — so clearing a field and never
* touching it are deliberately the same state.
*/
@Composable
fun ProviderParamFields(
specs: List<ParamSpec>,
values: Map<String, String>,
onChange: (Map<String, String>) -> Unit,
/**
* Whether to say which settings wait for a restart. False on a spawn form, where nothing is
* running yet and every setting is about to be read — saying it there would be a warning about
* a state the reader cannot be in.
*/
warnAboutRestart: Boolean,
modifier: Modifier = Modifier,
) {
if (specs.isEmpty()) return
Column(modifier.fillMaxWidth()) {
specs.forEach { spec ->
val set = { value: String ->
onChange(
// Blank clears rather than storing an empty string: the server reads an absent
// key as "use the default", and an empty one would be a value it then failed
// to parse.
if (value.isBlank()) values - spec.key else values + (spec.key to value)
)
}
when (spec.kind) {
"choice" -> {
// The first option is what unset means, so selecting it clears the key — see
// `ParamKind::Choice`. Without that the picker could show a default it could
// not return to.
val default = spec.options.firstOrNull().orEmpty()
ChipGroup(
label = spec.label + restartSuffix(spec, warnAboutRestart),
options = spec.options,
selected = values[spec.key] ?: default,
onSelect = { chosen -> set(if (chosen == default) "" else chosen) },
)
}
else ->
OutlinedTextField(
value = values[spec.key].orEmpty(),
onValueChange = set,
label = { Text(spec.label + restartSuffix(spec, warnAboutRestart)) },
placeholder = { Text(spec.unset) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = keyboardFor(spec.kind)),
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(16.dp))
}
if (warnAboutRestart && specs.any { it.restart }) {
Text(
"A setting marked “on restart” is saved now and read when this session's process " +
"next starts.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* Marks a control whose value will not take effect yet.
*
* On the label rather than beside it, because the reader decides whether to change the thing before
* they touch it — a note underneath is read after the decision.
*/
private fun restartSuffix(spec: ParamSpec, warn: Boolean): String =
if (warn && spec.restart) " (on restart)" else ""
/**
* The keyboard for a value's shape. A number field that opens the letter keyboard is one every
* entry is made harder by, and these are nearly all numbers.
*/
private fun keyboardFor(kind: String): KeyboardType =
when (kind) {
"integer" -> KeyboardType.Number
"decimal" -> KeyboardType.Decimal
else -> KeyboardType.Text
}
/**
* How long typing has to stop before edited settings are sent.
*
* Long enough that a number is one request rather than one per digit, short enough that closing the
* dialog straight after typing still saves — the save runs on the screen behind it, which outlives
* the dialog, so this delay is not a window the value can be lost in.
*/
const val PARAM_SAVE_DELAY_MS = 700L
@@ -0,0 +1,484 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* One provider on one machine: what it is, what its shared server is holding, and how each of its
* models is loaded.
*
* This is where a setting that belongs to a *machine* lives, as opposed to one that belongs to a
* session. The two were one list until llama.cpp sessions came to share one server per machine: how
* a model is loaded stopped being anything a single session could decide, because one copy of it in
* memory is what several sessions are talking to.
*
* It is also the only place a loaded model is taken out of memory. Nothing does that on its own —
* closing a session leaves the model loaded on purpose, since the next one to want it would
* otherwise pay the load again — so the memory is freed here, where what it costs everybody is
* visible.
*/
@Composable
fun ProviderScreen(
settings: ServerSettings,
machineId: String,
provider: String,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<ProviderView>>(LoadState.Loading) }
var reload by remember { mutableIntStateOf(0) }
var editing by remember { mutableStateOf<ProviderModel?>(null) }
var confirmingStop by remember { mutableStateOf(false) }
// What is being done to the server or to one of its models, in a word, and what went wrong
// when it did. Both here rather than per row: these act on the whole machine.
var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
var confirmingDelete by remember { mutableStateOf<ProviderModel?>(null) }
// The machine's own models and what is being fetched onto it. Only for a provider that serves
// files off that machine's disk -- everything else names its models rather than holding them,
// and a search for a GGUF under the Claude CLI would be an offer that leads nowhere.
val kind = (state as? LoadState.Loaded)?.value?.kind
val machineModels =
rememberMachineModels(
settings = settings,
machineId = machineId,
enabled = kind == "llama_cpp",
// A download that became a model is a model this screen has no settings for yet, so
// the view it is drawing is now one model short of the truth.
onLocalChange = { reload++ },
)
LaunchedEffect(reload) {
state =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchProvider(settings, machineId, provider))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// Say what is happening, do it, say what went wrong, refetch: every action on this screen
// changes what it is showing.
val act = { what: String, action: suspend () -> Unit ->
scope.launch {
busy = what
actionError =
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
busy = null
reload++
}
Unit
}
// The models search at the bottom takes the keyboard, and everything below the field it is
// typed in -- the Search button, the results -- is behind it without this.
Column(Modifier.fillMaxSize().imePadding().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = onBack) { Text("Back") }
}
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> {
val view = current.value
Text(view.name, style = MaterialTheme.typography.titleMedium)
Text(
"on ${view.machine}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
view.command?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(12.dp))
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
busy?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
Text(it, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
}
LazyColumn(Modifier.fillMaxSize()) {
view.server?.let { server ->
item("server") {
ServerCard(
server = server,
maxLoaded = view.maxLoaded,
enabled = busy == null,
onStop = { confirmingStop = true },
onMaxLoaded = { chosen ->
act("Saving…") {
setProviderSettings(
settings,
machineId,
provider,
chosen,
)
}
},
)
Spacer(Modifier.height(12.dp))
}
}
if (view.models.isNotEmpty() && view.modelParams.isNotEmpty()) {
item("models-heading") {
Text("Models", style = MaterialTheme.typography.titleSmall)
Text(
"How a model is loaded belongs to the machine, not to a session: " +
"one copy of it in memory answers every session using it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
}
}
machineModels.actionError?.let { failure ->
item("models-error") {
Text(failure, color = MaterialTheme.colorScheme.error)
}
}
// Above the models: this is what is about to be one of them.
downloadCards(machineModels)
val sizes = machineModels.sizes
uniqueItems(view.models, key = { it.id }) { model ->
ModelCard(
model = model,
specs = view.modelParams,
bytes = sizes[model.id],
onDelete =
if (model.id in sizes) ({ confirmingDelete = model }) else null,
// Tapping opens the settings; a provider whose models take none has
// nothing to open, so the row is not a control.
onEdit =
if (view.modelParams.isEmpty()) null else ({ editing = model }),
onUnload =
if (model.status == "loaded" || model.status == "sleeping") {
{
act("Unloading ${model.label}") {
unloadProviderModel(
settings,
machineId,
provider,
model.id,
)
}
}
} else null,
enabled = busy == null,
)
}
if (kind == "llama_cpp") modelSearch(machineModels)
if (view.mcpServers.isNotEmpty()) {
item("mcp") {
Spacer(Modifier.height(12.dp))
Text("Tool servers", style = MaterialTheme.typography.titleSmall)
Text(
view.mcpServers.joinToString(", ") +
" — configured on the backend, in its config file.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
editing?.let { model ->
val view = (state as? LoadState.Loaded)?.value
ModelSettingsDialog(
model = model,
specs = view?.modelParams.orEmpty(),
onDismiss = { editing = null },
onSave = { params ->
editing = null
act("Saving ${model.label}") {
setModelSettings(settings, machineId, provider, model.id, params)
}
},
)
}
confirmingDelete?.let { model ->
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete ${model.label}?") },
text = {
Text(
"The file is removed from ${(state as? LoadState.Loaded)?.value?.machine ?: "this machine"}. " +
"Nothing here can get it back -- downloading it again is the whole file again. " +
"Sessions using it keep their conversations and cannot start it."
)
},
confirmButton = {
TextButton(
onClick = {
confirmingDelete = null
machineModels.remove(model.id)
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
},
)
}
if (confirmingStop) {
AlertDialog(
onDismissRequest = { confirmingStop = false },
title = { Text("Stop this server?") },
text = {
// Said plainly rather than hidden: this is the only thing that frees the memory,
// and what it costs is that every session on this machine reloads its model.
Text(
"Every model it is holding is unloaded. Sessions using it will show as " +
"exited, and the next message to one loads its model again — which is " +
"the slow part, not the sending."
)
},
confirmButton = {
TextButton(
onClick = {
confirmingStop = false
act("Stopping…") { stopProviderServer(settings, machineId, provider) }
}
) {
Text("Stop")
}
},
dismissButton = { TextButton(onClick = { confirmingStop = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun ServerCard(
server: ServerState,
maxLoaded: Int?,
enabled: Boolean,
onStop: () -> Unit,
onMaxLoaded: (Int?) -> Unit,
) {
// The saved value is what this starts at and what Save is compared against, so a field left
// half-typed is visibly not saved rather than quietly either way.
val saved = maxLoaded?.toString().orEmpty()
var typed by remember(saved) { mutableStateOf(saved) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text("Model server", style = MaterialTheme.typography.titleSmall)
Text(
if (server.running) {
"Running" + (server.port?.let { ", reached on port $it" } ?: "")
} else {
// Not a fault: nothing is loaded because nothing has asked. Saying it in
// words rather than colouring the row, since "stopped" and "we could not
// ask" would otherwise look the same.
"Not running. A session starts it when it needs a model."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = typed,
onValueChange = { typed = it.filter(Char::isDigit) },
label = { Text("Models loaded at once") },
placeholder = { Text("one -- a second model replaces the first") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
// Shown whether or not it is running, and disabled when there is nothing to stop:
// a button that comes and goes makes its own absence the message.
TextButton(enabled = enabled && server.running, onClick = onStop) { Text("Stop") }
Spacer(Modifier.weight(1f))
TextButton(
enabled = enabled && typed != saved,
onClick = { onMaxLoaded(typed.toIntOrNull()) },
) {
Text("Save")
}
}
if (typed != saved) {
Text(
"Read when this server next starts.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ModelCard(
model: ProviderModel,
specs: List<ParamSpec>,
/** How big the file is on the machine, for a provider whose models are files. */
bytes: Long?,
onEdit: (() -> Unit)?,
onUnload: (() -> Unit)?,
onDelete: (() -> Unit)?,
enabled: Boolean,
) {
Card(
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.then(if (onEdit != null && enabled) Modifier.clickable(onClick = onEdit) else Modifier)
) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
model.label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
bytes?.let {
Text(
gigabytes(it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// What the server is doing with it, in its own word. Absent means nobody could ask --
// the server is not running -- and the line is left out rather than guessed at.
model.status?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (model.settings.isNotEmpty()) {
Text(
// In the words the dialog uses, and in the order it draws them: a summary
// naming `contextSize` is a summary of a different screen than the one it
// sits under.
specs
.mapNotNull { spec ->
model.settings[spec.key]?.let { "${spec.label} $it" }
}
.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (onUnload != null || onDelete != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Both shown whenever this kind of model has them, disabled rather than
// absent: unloading frees memory and deleting frees disk, and a button that
// comes and goes makes its own absence the message.
onUnload?.let { TextButton(enabled = enabled, onClick = it) { Text("Unload") } }
Spacer(Modifier.weight(1f))
onDelete?.let { TextButton(enabled = enabled, onClick = it) { Text("Delete") } }
}
}
}
}
}
/**
* How one model is loaded.
*
* Saved on Save rather than as it is typed, unlike the session settings dialog: writing this
* unloads the model for everybody using it, which is not something to do once per keystroke.
*/
@Composable
private fun ModelSettingsDialog(
model: ProviderModel,
specs: List<ParamSpec>,
onDismiss: () -> Unit,
onSave: (Map<String, String>) -> Unit,
) {
var params by remember(model.id) { mutableStateOf(model.settings) }
AlertDialog(
onDismissRequest = onDismiss,
// Every control here is a number, so the keyboard is up for most of this dialog's life --
// and a dialog that keeps its own size under the keyboard puts Save off the bottom of the
// screen, where nothing on screen says it is there. Taking the insets ourselves is what
// lets `imePadding` shrink it instead.
properties = DialogProperties(decorFitsSystemWindows = false),
modifier = Modifier.imePadding(),
title = { Text(model.label) },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(
if (model.status == "loaded" || model.status == "sleeping") {
"This model is loaded. Saving takes it out of memory, and the sessions " +
"using it load it again with these settings on their next message."
} else {
"Read when this model is next loaded."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
ProviderParamFields(
specs = specs,
values = params,
onChange = { params = it },
// Every one of these is read at load time, and the sentence above already
// says when that is -- marking each control "on restart" would repeat it six
// times.
warnAboutRestart = false,
)
}
},
confirmButton = { TextButton(onClick = { onSave(params) }) { Text("Save") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -1,10 +1,12 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -18,6 +20,14 @@ import androidx.compose.ui.unit.dp
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three * monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
* copies of "clip, fill, pad" drift apart the first time one is adjusted. * copies of "clip, fill, pad" drift apart the first time one is adjusted.
* *
* **Nothing in here wraps; it scrolls sideways instead.** This is column-aligned far more often
* than it is prose -- a diff, a table, a test run, a command and its arguments -- and wrapping
* destroys exactly the alignment that was carrying the meaning, while turning one line into four
* and a run of them into a wall. The scroll belongs to the block rather than to each line so that
* the lines stay aligned with each other as it moves: one offset for the whole column is what makes
* a shifted diff still read as a diff. Every [Text] inside is therefore drawn with `softWrap =
* false`, which is the half of this a caller has to remember.
*
* The colour is [rawSurface], which is also what a code block inside a reply is given. * The colour is [rawSurface], which is also what a code block inside a reply is given.
*/ */
@Composable @Composable
@@ -29,6 +39,10 @@ fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.()
// rectangle drawn at the same radius as the one behind it reads as a misprint. // rectangle drawn at the same radius as the one behind it reads as a misprint.
.clip(MaterialTheme.shapes.extraSmall) .clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface) .background(rawSurface)
// Clipped and filled before this, so the tint is the viewport and does not scroll away
// from under the text; padded after it, so the inset travels with the content and the
// last column does not end flush against the edge.
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
content = content, content = content,
) )
@@ -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 }
}
@@ -31,13 +31,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
/** /**
* A session wanting attention, said over the app 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 is right at a time. A row in the shade is for * Two places carry the same fact and they are doing different jobs: a row in the shade waits
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting * however long it has to, which makes it the record, and a banner is read now or not at all, which
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So * makes it the interruption. So somebody with the app open gets both -- this, and a silent row
* while these are on screen the stream is delivered here instead, which is arranged by the * behind it that is still there when they go looking and goes by itself when they open the session.
* collection below and nothing else. * Whether the app is open at all is this collection and nothing else.
* *
* A banner can go three ways, each somebody deciding something different: tapped, which opens the * A banner can go three ways, each somebody deciding something different: tapped, which opens the
* session; pushed off either side; or left alone, in which case it goes when the bar runs out. * session; pushed off either side; or left alone, in which case it goes when the bar runs out.
@@ -5,24 +5,32 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
@@ -30,12 +38,20 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -140,12 +156,23 @@ fun SessionImageViewer(
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref) val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
val view = LocalView.current
var hiddenBars by remember(ref) { mutableStateOf(ViewerBars()) }
var barInsets by remember(ref) { mutableStateOf(ViewerBarInsets()) }
Dialog( Dialog(
onDismissRequest = onClose, onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false), properties =
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) { ) {
ViewerSystemBars(hiddenBars)
Box( 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, contentAlignment = Alignment.Center,
) { ) {
when (val image = bitmap) { when (val image = bitmap) {
@@ -165,7 +192,46 @@ fun SessionImageViewer(
// beside it are. // beside it are.
CircularProgressIndicator(color = Color.White) 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%")
}
}
} }
} }
} }
@@ -225,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. * The image on its own, as large as it fits, with pinch to zoom.
* *
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back * Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image * gesture returns to the transcript instead of leaving the app. It opens fitted, with the whole
* visible. * image visible without enlarging a smaller one; the 100% control changes to one bitmap pixel per
* screen pixel and recenters it.
*/ */
@Composable @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 scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) } var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) }
val nativeScale = nativeScale(image.width, image.height, viewport.width, viewport.height)
LaunchedEffect(nativeSizeRequest, nativeScale) {
if (nativeSizeRequest > 0) {
scale = nativeScale
offsetX = 0f
offsetY = 0f
}
}
val bars =
viewerBars(
image.width,
image.height,
viewport.width,
viewport.height,
scale,
Offset(offsetX, offsetY),
barInsets,
)
SideEffect { onBarsChanged(bars) }
Image( Image(
bitmap = image, bitmap = image,
contentDescription = "Attached image", contentDescription = "Attached image",
contentScale = ContentScale.Fit, contentScale = ContentScale.Inside,
// Zoomed in, the reader is looking at pixels on purpose. // Zoomed in, the reader is looking at pixels on purpose.
filterQuality = FilterQuality.None, filterQuality = FilterQuality.None,
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.pointerInput(Unit) { .onSizeChanged(onViewportChanged)
detectTransformGestures { _, pan, zoom, _ -> .pointerInput(nativeScale) {
// Floor of 1 so the image cannot be pinched smaller than fitted, which is detectTransformGestures { centroid, pan, zoom, _ ->
// already the whole of it; a ceiling so it cannot be lost off-screen. val oldScale = scale
scale = (scale * zoom).coerceIn(1f, 8f) val maximumScale = maxOf(8f, nativeScale)
if (scale > 1f) { val newScale = (oldScale * zoom).coerceIn(1f, maximumScale)
offsetX += pan.x if (newScale > 1f) {
offsetY += pan.y val offset =
zoomOffset(
Offset(offsetX, offsetY),
centroid,
pan,
oldScale,
newScale,
Offset(size.width / 2f, size.height / 2f),
)
offsetX = offset.x
offsetY = offset.y
} else { } else {
offsetX = 0f offsetX = 0f
offsetY = 0f offsetY = 0f
} }
scale = newScale
} }
} }
.graphicsLayer { .graphicsLayer {
@@ -263,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 package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -12,11 +14,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -29,14 +35,30 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* The sessions tab: 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. * No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
* What stays here is the button that adds a session, because that acts on this list and nothing * What stays here is the button that adds a session, because that acts on this list and nothing
@@ -48,10 +70,23 @@ fun SessionListScreen(
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
/** A session this list has just deleted, for whoever is showing it elsewhere. */
onDeleted: (String) -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) } var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Which rows the reader has picked out. Empty means selection mode is off, as on the import
// tab: a selection mode with nothing in it has no controls and no way out but Back.
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
// The sessions a delete has been confirmed for, or none. A list rather than one session,
// because a selection is what the bar below acts on.
var confirmingDelete by remember { mutableStateOf<List<SessionSummary>>(emptyList()) }
// Whether an answer is outstanding, which is a different question from whether there is
// anything to draw: see [refresh].
var reloading by remember { mutableStateOf(false) }
// Failures that belong to one session rather than to the list, keyed by its id and shown on its // Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused, // own card. The two scopes are decided by whether the server answered: it answered and refused,
@@ -60,6 +95,10 @@ fun SessionListScreen(
// Cleared on the next successful load below -- an entry outlives its session otherwise. // Cleared on the next successful load below -- an entry outlives its session otherwise.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) } var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Why the order on screen is not the order that was saved, when saving one failed. The list is
// what failed, so it is reported over the list rather than on any row.
var orderError by remember { mutableStateOf<String?>(null) }
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because // Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
// the rows are rebuilt from whatever the server last said and this belongs to the request. // the rows are rebuilt from whatever the server last said and this belongs to the request.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) } var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
@@ -70,31 +109,142 @@ fun SessionListScreen(
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
fun refresh() { 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 { scope.launch {
listState = listState =
try { try {
val loaded = val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap() deleteErrors = emptyMap()
val alive = loaded.value.map { it.id }.toSet()
// A selection is of sessions, so one deleted somewhere else leaves it. Only
// that one: the other rows the reader picked out are still there.
selected = selected.intersect(alive)
// The path out for a cached transcript whose session was deleted somewhere // The path out for a cached transcript whose session was deleted somewhere
// else. This list is the only place that ever learns the full set. On the // else. This list is the only place that ever learns the full set. On the
// answer rather than in `finally`: a list that failed to arrive says nothing // answer rather than in `finally`: a list that failed to arrive says nothing
// about which sessions exist. // about which sessions exist.
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { transcriptCache.retainOnly(alive) }
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
}
loaded loaded
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
reloading = false
}
}
/**
* Deletes every session in [targets], one after another.
*
* One at a time and in the order they are drawn: the server has no batch delete for sessions,
* and each one ends a process. Each row says what is happening to it from the moment the work
* is handed over, which is also when the selection goes -- a bar still naming sessions being
* deleted is a set nobody can act on.
*/
fun deleteChosen(targets: List<SessionSummary>, alsoDeleteForeign: Boolean) {
selected = emptySet()
// Marked here rather than after the request returns: a row has to say something is
// happening to it from the moment it is asked for.
deleting = deleting + targets.map { it.id }
deleteErrors = deleteErrors - targets.map { it.id }.toSet()
scope.launch {
for (session in targets) {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the session
// exactly as it was, and its transcript with it.
transcriptCache.session(TranscriptAddress(session.id)).purge()
}
// Only this row, and only what changed. Refetching the list instead put every
// other session back through loading and handed the reader an empty screen, to
// report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState = LoadState.Loaded(loaded.value.filterNot { it.id == session.id })
}
onDeleted(session.id)
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the session it
// refused about is exactly as it was.
deleteErrors = deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
} }
} }
LaunchedEffect(reloadToken) { refresh() } LaunchedEffect(reloadToken) { refresh() }
val rows = rememberLazyListState()
val reorder =
rememberReorder(
listState = rows,
onMove = { from, to ->
// Moved here and now, because the row is under a finger: waiting for the server to
// agree would drag the handle away from the card it is on. What the server thinks
// is asked for when the finger comes up, and a refusal puts the list back.
val loaded = listState
if (loaded is LoadState.Loaded) {
val moved = loaded.value.toMutableList()
moved.add(to, moved.removeAt(from))
listState = LoadState.Loaded(moved)
}
},
onSettled = {
val loaded = listState
if (loaded is LoadState.Loaded) {
val order = loaded.value.map { it.id }
scope.launch {
try {
withContext(Dispatchers.IO) { reorderSessions(settings, order) }
orderError = null
} catch (e: ApiException) {
orderError = e.message ?: "The new order couldn't be saved"
// The screen must not go on showing an arrangement nothing kept, so
// the server's own order comes back -- which is also the only way to
// see what it does think.
refresh()
}
}
}
},
)
// Back leaves selection mode rather than the tab, which is the level it is one step above.
// Nested inside MainScreen's own handler, so it wins while there is a selection.
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
// can still be scrolled to while it is up.
var barHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
// What the bar covers *now*: its measurement is kept while it is away, but nothing is
// reserved for a bar that is not up.
val covered = if (selected.isEmpty()) 0.dp else barHeight
// The spawn button floats over the list, so the list ends above it -- measured, for the
// reason the bar is. Without this the last row sat under the button, which was survivable
// while every part of a row did the same thing and is not now that corner is a handle.
var buttonHeight by remember { mutableStateOf(0.dp) }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
orderError?.let { message ->
// The server's own words, unprefixed, the way every other failure is shown.
Text(
message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
}
when (val state = listState) { when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is already a whole // The message as Api.kt wrote it, with nothing added: it is already a whole
@@ -113,20 +263,32 @@ fun SessionListScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
// Awaiting-answer first (the point of the screen), then most recently active. LazyColumn(
val ordered = state = rows,
state.value.sortedWith( contentPadding =
compareByDescending<SessionSummary> { it.status == "awaitingInput" } PaddingValues(bottom = covered + buttonHeight + BUTTON_RING * 2),
.thenByDescending { it.lastActivity } ) {
) uniqueItems(state.value, key = { it.id }) { session ->
LazyColumn {
uniqueItems(ordered, key = { it.id }) { session ->
SessionCard( SessionCard(
session = session, session = session,
error = deleteErrors[session.id], error = deleteErrors[session.id],
deleting = session.id in deleting, deleting = session.id in deleting,
onOpen = { onOpen(session) }, picked = session.id in selected,
onLongPress = { confirmingDelete = session }, // 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)) Spacer(Modifier.height(12.dp))
} }
@@ -135,70 +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( FloatingActionButton(
onClick = onSpawn, 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) Text("+", style = MaterialTheme.typography.headlineMedium)
} }
} }
confirmingDelete?.let { session -> val targets = confirmingDelete
// Reset per session, so a toggle turned on for one conversation is not still on for the if (targets.isNotEmpty()) {
// next. Off to begin with: see [deleteSession]. // Reset per selection, so a toggle turned on for one set of conversations is not still
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) } // 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( AlertDialog(
onDismissRequest = { confirmingDelete = null }, onDismissRequest = { confirmingDelete = emptyList() },
title = { Text("Delete \"${session.title}\"?") }, title = {
Text(
if (targets.size == 1) "Delete \"${targets.first().title}\"?"
else "Delete ${targets.size} sessions?"
)
},
text = { text = {
// Two different acts behind one button, so it says which one this is. What // Two different acts behind one button, so it says which one this is. What
// separates them is whether the *driver* keeps its own record of the conversation // separates them is whether the *driver* keeps its own record of the
// -- the Claude Code CLI does, whether this app spawned the session or imported it; // conversation
// -- the coding CLIs do, whether this app spawned the session or imported it;
// echo and llama.cpp do not. // echo and llama.cpp do not.
// //
// This used to branch on `imported`, above a comment asserting that "a session // This used to branch on `imported`, above a comment asserting that "a session
// started here has no copy anywhere". That was false for every claude-cli session // 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 app spawned, and getting it wrong in that direction is the expensive one:
// "this can't be undone", said of something that can, spends the credibility the // "this can't be undone", said of something that can, spends the credibility that
// sentence needs. // sentence needs.
// //
// Neither branch promises a restore. The recoverable one says what is known -- the // Neither branch promises a restore. The recoverable one says what is known,
// driver keeps its own record -- rather than that the file is still there, and it // that the driver keeps its own record, rather than that the file is still there,
// names what goes either way, because this app's transcript holds images, peer // and it names what goes either way, because this app's transcript holds images,
// messages and commands the CLI's own record never had. // 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 { Column {
Text( Text(
when { when {
!session.keepsOwnTranscript -> owned.isEmpty() ->
"Kills the process and deletes the conversation. Nothing else " + "Kills the process and deletes the conversation. Nothing else " +
"keeps a copy, so this can't be undone." "keeps a copy, so this can't be undone."
// The sentence below is the one the toggle makes false, which is why it // The sentence below is the one the toggle makes false, which is
// is written twice rather than appended to: leaving "should still be // why it is written twice rather than appended to: "should still be
// there to import again" on screen beside a switch that removes it is // there to import again", left on screen beside a switch that removes
// the reassurance being read at the moment it stops being true. // it, is the reassurance being read as it stops being true.
alsoDeleteForeign -> alsoDeleteForeign ->
"Kills the process and deletes both copies of the conversation: " + "Kills the process and deletes both copies of the conversation: " +
"this app's, and 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." "machine. Nothing keeps another, so this can't be undone."
else -> else ->
"Stops the process and deletes this app's copy of the " + "Stops the process and deletes this app's copy of the " +
"conversation, including any images, peer messages and " + "conversation, including any images, peer messages and " +
"commands recorded only here. Claude Code keeps its own " + "commands recorded only here. $transcriptOwner keeps its own " +
"transcript on the machine, so the conversation itself " + "transcript on the machine" +
"should still be there to import again." (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 // Only where there is a second copy to decide about. Absent rather than
// disabled, because this is not a capability being withheld: for echo and // disabled, because this is not a capability being withheld: for echo and
// llama.cpp there is no other transcript, and a switch offering to delete one // llama.cpp there is no other transcript, and a switch offering to delete one
// would be asking about something that does not exist. // would be asking about something that does not exist.
if (session.keepsOwnTranscript) { if (owned.isNotEmpty()) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Its own row rather than beside the paragraph: a switch is taller than a // Its own row rather than beside the paragraph: a switch is taller
// line of text and re-centres whatever shares a row with it. // than a line of text and re-centres whatever shares a row with it.
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"Delete Claude Code's transcript too", "Delete $transcriptOwner's transcript too",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -214,38 +420,8 @@ fun SessionListScreen(
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
confirmingDelete = null confirmingDelete = emptyList()
// Marked here rather than after the request returns: the row has to say deleteChosen(targets, alsoDeleteForeign)
// something is happening to it from the moment it is asked for.
deleting = deleting + session.id
deleteErrors = deleteErrors - session.id
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it.
transcriptCache.session(session.id).purge()
}
// Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the
// reader an empty screen, to report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState =
LoadState.Loaded(
loaded.value.filterNot { it.id == session.id }
)
}
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the
// session it refused about is exactly as it was.
deleteErrors =
deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
} }
) { ) {
// Coloured by consequence: this takes something away, and does so wherever it // Coloured by consequence: this takes something away, and does so wherever it
@@ -254,12 +430,54 @@ fun SessionListScreen(
} }
}, },
dismissButton = { 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SessionCard( private fun SessionCard(
@@ -274,22 +492,53 @@ private fun SessionCard(
* about a request that has not been answered yet. * about a request that has not been answered yet.
*/ */
deleting: Boolean, deleting: Boolean,
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, onLongPress: () -> Unit,
) { ) {
val held = reorder?.held == session.id
BusyItem(label = if (deleting) "deleting" else null) { BusyItem(label = if (deleting) "deleting" else null) {
Card( Card(
// Off while the delete is in flight: a card that still opens a session it is deleting colors =
// is a race the reader can start by tapping. On the card rather than in [BusyItem], if (picked)
// which leaves gestures alone so the list still scrolls. 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() Modifier.fillMaxWidth()
// Drawn where the finger has taken it, above the rows it is passing over. Both
// in the layer rather than in the layout, so nothing around it moves and the
// list does not remeasure per frame of a drag.
.zIndex(if (held) 1f else 0f)
.graphicsLayer { translationY = reorder?.offsetOf(session.id) ?: 0f },
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
// Everything but the handle, which is what makes the two gestures separate
// rather than competing: a press that lands on the handle never reaches this,
// so holding it cannot select the row it is about to move. The card had the
// click while the handle was the only thing inside it that did not want one,
// and a hold on the handle then both selected the row and ate the drag.
//
// Off while the delete is in flight: a card that still opens a session it is
// deleting is a race the reader can start by tapping. Here rather than in
// [BusyItem], which leaves gestures alone so the list still scrolls.
Modifier.weight(1f)
.combinedClickable( .combinedClickable(
enabled = !deleting, enabled = !deleting,
onClick = onOpen, onClick = onClick,
onLongClick = onLongPress, onLongClick = onLongPress,
) )
.padding(CARD_PADDING)
) { ) {
Column(Modifier.padding(16.dp)) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -300,15 +549,23 @@ private fun SessionCard(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
StatusText(session.status) StatusText(session.status)
if (session.backgroundTasks > 0) {
Text(
backgroundTaskLabel(session.backgroundTasks),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
Text( Text(
// Machine, then what runs on it, then what it is set to: the same order and // Machine, then what runs on it, then what it is set to: the same order
// separator as the session screen's header and the usage dialog, so one // and separator as the session screen's header and the usage dialog, so
// pair of facts is not written three ways. // one pair of facts is not written three ways.
listOfNotNull( listOfNotNull(
session.setupName, session.machineName,
session.provider, session.provider,
session.model?.let { modelLabel(it) }, session.model?.let { modelLabel(it) },
) )
@@ -333,24 +590,31 @@ private fun SessionCard(
) )
} }
} }
// 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),
)
}
}
} }
} }
} }
@Composable @Composable
fun StatusText(status: String) { fun StatusText(status: String) {
val (label, color) = val label = sessionStatusWord(status)
when (status) { val color = sessionStatusColour(status)
"awaitingInput" -> "your turn" to awaitingColor
"running" -> "running" to runningColor
"compacting" -> "compacting" to commandColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree: the
// session is not idle and has not exited, nobody has been able to find out which. A
// muted colour alone would read as one of the quiet states.
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (sessionWorking(status)) { if (sessionWorking(status)) {
// The same colour as the word beside it: the two are one signal, and a spinner in the // The same colour as the word beside it: the two are one signal, and a spinner in the
File diff suppressed because it is too large. Load diff
@@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -26,6 +28,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -38,8 +44,10 @@ import kotlinx.coroutines.withContext
* screen of its own until 2026-08-30, which put a page transition and a back stack around two * screen of its own until 2026-08-30, which put a page transition and a back stack around two
* controls and hid the thing they act on. * controls and hid the thing they act on.
* *
* The model and the permission mode are deliberately still on the session's own bar, because those * The model and the permission mode are on the session's own bar as well, because those are changed
* are changed *while* reading a turn -- "not this model, try that one". * *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 * Captions are for what a control costs rather than for what it is. A paragraph under every control
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move * made the dialog longer than the conversation it covers -- so Notifications has none, while Move
@@ -54,6 +62,37 @@ fun SessionSettingsDialog(
*/ */
title: String, title: String,
onRenamed: (String) -> Unit, onRenamed: (String) -> Unit,
/**
* How hard the model thinks, or null for the CLI's own default.
*
* 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 * What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it. * the Reload row below, which is what would discard it.
@@ -69,6 +108,7 @@ fun SessionSettingsDialog(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) } var name by remember(sessionId) { mutableStateOf(title) }
var effortError by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) } var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of // Null until the server has been asked. The row this dialog was opened over is a snapshot of
@@ -77,6 +117,16 @@ fun SessionSettingsDialog(
// and a spinner sits beside it, which is what not knowing looks like. // and a spinner sits beside it, which is what not knowing looks like.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) } var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(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 // 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 // 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 // same as one whose directory is unknown -- the field is only enabled once one of those is
@@ -90,6 +140,9 @@ fun SessionSettingsDialog(
try { try {
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) } val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
notify = fresh.notify notify = fresh.notify
autoResume = fresh.autoResume
resumeMessage = fresh.autoResumeMessage
resumeAt = fresh.resumeAt
cwd = fresh.cwd.orEmpty() cwd = fresh.cwd.orEmpty()
typedCwd = fresh.cwd.orEmpty() typedCwd = fresh.cwd.orEmpty()
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -97,6 +150,8 @@ fun SessionSettingsDialog(
// instead of offering a position nothing confirmed. // instead of offering a position nothing confirmed.
notifyError = e.message notifyError = e.message
notify = null notify = null
resumeError = e.message
autoResume = null
} }
} }
@@ -125,6 +180,26 @@ fun SessionSettingsDialog(
} }
} }
/**
* Chooses a thinking level, which ends the process the old level was launched with.
*
* Put back if the request is refused, for the reason the notification switch below gives: a
* control that stays where it was put after a refusal is stating something untrue.
*/
fun setEffort(chosen: String?) {
val was = 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 // 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, // 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. // and one that stays moved after a refusal lies.
@@ -142,6 +217,39 @@ fun SessionSettingsDialog(
} }
} }
/**
* Turns auto-resume on or off, or changes what it would say.
*
* One request for both, because the server takes one: switching it on and typing the message
* are two halves of the same decision, and sending them separately would leave a moment where
* the session is armed with the old words.
*
* Put back if refused, like the notification switch. Turning it off also clears what was
* scheduled -- said here rather than only on the server, or the row would go on naming a time
* that no longer exists.
*/
fun setAutoResume(on: Boolean, message: String) {
val wasOn = autoResume
val wasMessage = resumeMessage
val wasAt = resumeAt
autoResume = on
resumeMessage = message
if (!on) resumeAt = null
resumeError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
setSessionAutoResume(settings, sessionId, on, message)
}
} catch (e: ApiException) {
autoResume = wasOn
resumeMessage = wasMessage
resumeAt = wasAt
resumeError = e.message
}
}
}
// Nothing to do when the name has not changed, so the button says so rather than sending a // 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. // request whose success would look exactly like the failure of having typed nothing.
val changed = name.trim().isNotEmpty() && name.trim() != title val changed = name.trim().isNotEmpty() && name.trim() != title
@@ -168,7 +276,10 @@ fun SessionSettingsDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Session settings") }, title = { Text("Session settings") },
text = { text = {
Column { // Scrollable, because this dialog grew past a screenful: a Material dialog constrains
// its own height and clips what does not fit, so the last control on the list is one
// large system font away from being unreachable with nothing on screen to say so.
Column(Modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -212,6 +323,70 @@ fun SessionSettingsDialog(
) )
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
if (autoResume == null && resumeError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = autoResume == true,
onCheckedChange = { setAutoResume(it, resumeMessage) },
enabled = autoResume != null,
)
}
// Disabled rather than hidden while the switch is off: a field that comes and goes
// makes its own presence the signal, and a visible one teaches what the switch will
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
// sentence is one request instead of one per letter.
OutlinedTextField(
value = resumeMessage,
onValueChange = { resumeMessage = it },
label = { Text("Message to send") },
// What an empty field means, in the field: the server's own word rather than a
// session poked with nothing to read.
placeholder = { Text(DEFAULT_RESUME_MESSAGE) },
singleLine = true,
enabled = autoResume == true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
)
// What it does and what it costs, in the order it happens. The last sentence is the
// one that matters: the time below is when the server will *ask*, not a promise
// about when the session speaks.
Text(
"When this session stops because the account is out of quota, the server " +
"checks the limit and sends this message once it has lifted. It checks " +
"again if the limit is still on.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Only where something is actually waiting. Absent is not a state worth a row: a
// session that has not hit a limit has nothing scheduled, which the reader can see
// from the switch.
resumeAt?.let { at ->
Text(
"Waiting now -- next check ${formatCheckTime(at)}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
resumeError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -257,6 +432,75 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
choices.forEach { choice ->
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(choice.label, modifier = Modifier.weight(1f))
PickerButton(
current = choice.current,
options = choice.options,
onPick = choice.onPick,
)
}
}
// Left out rather than disabled, the one place this dialog does that: a disabled
// control teaches what the thing can do, and a llama session cannot do this at all
// -- the row would be teaching something false about it.
if (takesEffort) {
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Thinking", modifier = Modifier.weight(1f))
PickerButton(
current = effort ?: DEFAULT_EFFORT,
// The level the CLI picks for itself is in the list as well as in the
// button, so leaving a level is not a one-way trip -- the same
// correction the model picker carries.
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
onPick = { chosen ->
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
},
)
}
// What it costs, said where it is about to be pressed, like Move above: the
// CLI reads the level when it launches and has no control request for
// changing one.
Text(
"Changing this stops the session's process. It starts again with the " +
"next message, or with Start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
effortError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
if (paramSpecs.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
Text(
"Model settings",
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
// Edited here and saved by the screen behind this, which is what makes
// typing in a text field affordable: the save is debounced, and a dialog
// dismissed mid-edit would take an unsaved value with it.
ProviderParamFields(
specs = paramSpecs,
values = params,
onChange = onParamsChanged,
warnAboutRestart = true,
)
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -329,3 +573,34 @@ fun SessionSettingsDialog(
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
) )
} }
/**
* 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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.Duration import java.time.Duration
@@ -70,12 +72,21 @@ class UsageFeed(
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */ /** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit, val refresh: () -> Unit,
) { ) {
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */ /**
fun forSetup(setup: String): SessionUsage = * What meters [session], and what that meter came back as. See [usageFor] for the states.
when (val state = snapshots) { *
* A session rather than a machine, because a machine is not what is metered: one machine runs
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
*/
fun forSession(session: SessionSummary): SessionUsage {
// Settled without asking anybody: a session nothing meters has nothing to check, and
// "checking" is what the fetch's own states would say about it for as long as one is out.
val provider = session.usageProvider ?: return SessionUsage.NotMetered
return when (val state = snapshots) {
is LoadState.Loading -> SessionUsage.Waiting is LoadState.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message) is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, setup) is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model)
}
} }
} }
@@ -116,8 +127,8 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
* *
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a * Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked. * blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows came back rather than the three Claude sends today -- the backend * Taken over however many windows this session's provider returned rather than the three Claude
* passes windows it does not recognise straight through. * 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 * Every state that is not a measurement takes the ordinary control colour instead. That is the
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an * point where colour stops being able to help: blue is the low end of a scale here, so colouring an
@@ -133,7 +144,7 @@ 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 * Here rather than only in the usage dialog because it is the number that decides whether to keep
* going, and it was a screen away from the place that decision gets made. It reports on this * going, and it was a screen away from the place that decision gets made. It reports on this
@@ -150,17 +161,17 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal // rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved // value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
// would sit at a stale figure for hours. // would sit at a stale figure for hours.
var now by remember { mutableStateOf(OffsetDateTime.now()) } val now = rememberUsageNow()
LaunchedEffect(Unit) {
while (true) {
delay(REFRESH_MS)
now = OffsetDateTime.now()
}
}
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would report a // Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// problem about a setup somebody chose, on every screen, forever. // a problem about a machine somebody chose, on every screen, forever.
if (usage is SessionUsage.NotMetered) { //
// And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
// second and was then taken away. A row that has to be withdrawn is worse than one that
// arrives late.
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
return return
} }
@@ -171,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 // Words, not a colour and not an empty bar: every one of these is a different kind of
// answer from "this much is used", and only words carry a difference in kind. // answer from "this much is used", and only words carry a difference in kind.
when (val state = usage) { when (val state = usage) {
SessionUsage.NotMetered -> Unit // Both handled above, before the row exists at all.
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}") SessionUsage.NotMetered,
SessionUsage.Waiting -> UsageNote("5-hour usage: checking") SessionUsage.Waiting -> Unit
is SessionUsage.Unavailable -> UsageNote("Usage unknown -- ${state.why}")
is SessionUsage.Known -> { is SessionUsage.Known -> {
val window = state.windows.firstOrNull { it.kind == "session" } val window = shortestUsageWindow(state.windows)
if (window == null) { if (window == null) {
UsageNote("5-hour usage unknown -- no five-hour window reported") UsageNote("Usage unknown -- no window duration was reported")
} else { } else {
LinearProgressIndicator( UsageProgressIndicator(window, now, Modifier.weight(1f))
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
// The same step at the same percentages as the dialog's bars: this is the
// same measurement, and a reader who learned the colour there has to be
// able to read it here without checking which screen they are on.
color = quotaColor(window.percent),
modifier = Modifier.weight(1f),
)
Text( Text(
fiveHourLabel(window, now), usageWindowLabel(window, now),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
@@ -199,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. */ /** Anything this row says instead of drawing a bar, so all of them look the same. */
@Composable @Composable
private fun UsageNote(text: String) { private fun UsageNote(text: String) {
@@ -210,42 +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 * The percentage on its own does not answer the question it gets asked, which is whether to start
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers. * something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
* *
* The window's *length* is what the provider's own name for it used to carry ("5-hour window"), and
* it is worth more beside the time left than in front of the percentage: "3h 42m left / 5h" says in
* one reading both how much of the cycle is to come and which cycle this is. Where the provider
* reported no duration there is simply nothing after the span -- the name it gave is not a
* measurement of one, so nothing is inferred from it.
*
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window * The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
* that is not running gets the percentage and nothing else. * that is not running gets the percentage and nothing else.
*/ */
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%" val percent = "${window.percent.toInt()}%"
val outOf =
window.durationMinutes?.takeIf { it > 0 }?.let { " / ${formatMillis(it * 60_000)}" } ?: ""
return when (val end = windowEnd(window.resetsAt, now)) { return when (val end = windowEnd(window.resetsAt, now)) {
// Between blocks the five-hour window has no reset time, and saying so is a fact about // Between blocks a window can have no reset time, and saying so is a fact about nothing:
// nothing: there is no window to run out. The percentage is the whole answer. // there is no window to run out. The percentage is the whole answer.
WindowEnd.NotRunning -> percent WindowEnd.NotRunning -> percent
WindowEnd.Unreadable -> "$percent · reset time unreadable" WindowEnd.Unreadable -> "$percent · reset time unreadable"
is WindowEnd.Ends -> is WindowEnd.Ends ->
// Under a minute, including past the end: the number would round to "0m left", which // Under a minute, including past the end: the number would round to "0m left", which
// reads as a measurement rather than as the window having run out. // reads as a measurement rather than as the window having run out.
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon" if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
else "$percent · ${formatSpan(end.until)} left" 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].
*
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
* service on one machine.
* *
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it. * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the * None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered. * machine having no quota rather than the question going unanswered.
*/ */
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage { fun usageFor(
// No snapshot at all means the backend never asked, which it only does for a machine with snapshots: List<UsageSnapshot>,
// nothing metered on it. That is a different answer from having asked and failed. machine: String,
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered 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") { 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) 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)
@@ -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()
}
}
@@ -48,44 +48,52 @@ fun SpawnScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// What the form is made of, and whether we have it yet. A failure here is not the same as a // What the form is made of, and whether we have it yet. A failure here is not the same as a
// server with nothing to offer, so it must not reach the pickers as empty lists. // server with nothing to offer, so it must not reach the pickers as empty lists.
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) } 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 // Machine first, then one of its providers. Choosing a machine can invalidate the provider, so
// provider is stored by name and resolved against the current setup rather than held as an // 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. // object that could outlive the list it came from.
var setupName by remember { mutableStateOf<String?>(null) } var machineName by remember { mutableStateOf<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) } var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") } var model by remember { mutableStateOf("") }
var providerModels by remember { mutableStateOf<List<OfferedModel>>(emptyList()) }
var providerModelsLoading by remember { mutableStateOf(false) }
var providerModelsError by remember { mutableStateOf<String?>(null) }
var cwd by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") }
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and // Set only after the selected provider reports its own default. An empty value is not sent.
// answering "allow Bash?" dozens of times per task is what this app exists to avoid. var permissionMode by remember { mutableStateOf("") }
var permissionMode by remember { mutableStateOf("auto") } // Null until the server has been asked, and null again if it answers "no level chosen" -- the
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
// arrives is one you can spawn at without having chosen it.
var effort by remember { mutableStateOf<String?>(null) }
var defaultsAsked by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// form worth keeping, and that one leaves nothing to fill in. // form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) } var spawnError by remember { mutableStateOf<String?>(null) }
// Downloaded models, for a llama provider to choose between. Kept separate from the setups: a // Whatever the chosen provider says it takes, by key. Empty until something is typed: an
// Claude session needs none, so failing to list them must not stop the screen rendering. // absent key means the server's own default, which is what every field's placeholder says.
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) } var params by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") }
var temperature by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Separate from the machines fetch below and deliberately not fatal: failing to learn the
// default must leave a screen you can still spawn from, so the picker stays on "default"
// and says so rather than the whole form refusing to draw.
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
.onSuccess { effort = it }
defaultsAsked = true
options = options =
try { try {
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) } val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) }
val first = fetched.firstOrNull() val first = fetched.firstOrNull()
setupName = first?.name machineName = first?.name
providerName = first?.providers?.firstOrNull()?.name providerName = first?.providers?.firstOrNull()?.name
LoadState.Loaded(fetched) LoadState.Loaded(fetched)
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
models =
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
.getOrDefault(emptyList())
} }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
@@ -102,7 +110,7 @@ fun SpawnScreen(
// Nothing below is fillable until the options are here, and a failure to fetch them leaves // Nothing below is fillable until the options are here, and a failure to fetch them leaves
// no form worth showing -- so this reports and stops, rather than offering empty pickers // no form worth showing -- so this reports and stops, rather than offering empty pickers
// under an error message. // under an error message.
val setups = val machines =
when (val state = options) { when (val state = options) {
is LoadState.Loading -> { is LoadState.Loading -> {
CircularProgressIndicator() CircularProgressIndicator()
@@ -114,51 +122,88 @@ fun SpawnScreen(
} }
is LoadState.Loaded -> state.value is LoadState.Loaded -> state.value
} }
val setup = setups.firstOrNull { it.name == setupName } val machine = machines.firstOrNull { it.name == machineName }
val current = setup?.providers?.firstOrNull { it.name == providerName } val current = machine?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and permission modes; keying the // Coding CLIs take a working directory, model, permission mode and thinking level. Keying
// extra fields on the kind rather than the provider name keeps a second Claude provider // the extra fields on the kind rather than the provider name keeps a second installation
// from needing anything here. // from needing anything here.
val isClaude = current?.kind == "claude_cli" val isClaude = current?.kind == "claude_cli"
val isCodex = current?.kind == "codex_cli"
val isCodingCli = isClaude || isCodex
val isLlama = current?.kind == "llama_cpp" val isLlama = current?.kind == "llama_cpp"
// Echo is the only kind with nothing to choose between.
val offersModels = isCodingCli || isLlama
// Where a session's tools act, which is the only thing a working directory decides.
val takesCwd = isCodingCli || isLlama
// Whichever machine and provider are chosen now, asked again when either changes. The
// previous answer is dropped first rather than left on screen: a model name from another
// machine looks exactly like one from this one.
LaunchedEffect(machine?.id, current?.name) {
model = ""
// A key from the previous provider would be a setting this one does not have, drawn
// by no control and sent at the spawn anyway.
params = emptyMap()
providerModels = emptyList()
providerModelsError = null
permissionMode = current?.defaultPermissionMode.orEmpty()
// Every kind that offers models at all, not only the coding CLIs: a llama provider
// answers with the GGUFs on the machine it runs on, through the same call. One
// question with one answer is what keeps the picker free of a branch on the kind.
if (machine == null || current == null || !offersModels) {
providerModelsLoading = false
return@LaunchedEffect
}
providerModelsLoading = true
try {
providerModels =
withContext(Dispatchers.IO) {
fetchProviderModels(settings, machine.id, current.name)
}
} catch (e: ApiException) {
providerModelsError = e.message
} finally {
providerModelsLoading = false
}
}
// The machine first, because it decides what can be run at all. // The machine first, because it decides what can be run at all.
ChipGroup( ChipGroup(
label = "Setup", label = "Machine",
options = setups.map { it.name }, options = machines.map { it.name },
selected = setupName, selected = machineName,
onSelect = { name -> onSelect = { name ->
setupName = name machineName = name
// The provider list changes with the machine, so a name carried over from the // The provider list changes with the machine, so a name carried over from the
// previous one would be a selection that isn't in the picker. Take that machine's // previous one would be a selection that isn't in the picker. Take that machine's
// first. // first.
providerName = providerName =
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
}, },
) )
setup?.address?.let { machine?.address?.let {
Text( Text(
it, it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// The address belongs to the setup above it, not to the provider label below; without // The address belongs to the machine above it, not to the provider label below; without
// this they read as one block. // this they read as one block.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
// Only what this machine actually has. A setup with none says so rather than showing an // Only what this machine actually has. A machine with none says so rather than showing an
// empty row that reads as a failure. // empty row that reads as a failure.
if (setup != null && setup.providers.isEmpty()) { if (machine != null && machine.providers.isEmpty()) {
Text( Text(
"\"${setup.name}\" has no providers configured.", "\"${machine.name}\" has no providers configured.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
ChipGroup( ChipGroup(
label = "Provider", label = "Provider",
options = setup?.providers?.map { it.name }.orEmpty(), options = machine?.providers?.map { it.name }.orEmpty(),
selected = providerName, selected = providerName,
onSelect = { providerName = it }, onSelect = { providerName = it },
) )
@@ -174,58 +219,59 @@ fun SpawnScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (isLlama) { if (offersModels) {
// A llama session names one of the models this backend has downloaded, so the choice is when {
// that list rather than free text -- a name that is not on disk is a session that providerModelsLoading ->
// cannot start.
if (models.isEmpty()) {
Text( Text(
"No models downloaded yet. Get one from the Models screen first.", "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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { providerModels.isEmpty() ->
ChipGroup( Text(
label = "Model", "This machine reported no selectable models.",
// The file, not the whole key: the repository is the same for every style = MaterialTheme.typography.bodySmall,
// quantisation of a model, so the file name is what tells two of them apart. color = MaterialTheme.colorScheme.onSurfaceVariant,
options = models.map { it.file },
selected = models.firstOrNull { it.key == modelKey }?.file,
onSelect = { file -> modelKey = models.first { it.file == file }.key },
) )
} else -> {
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = contextSize,
onValueChange = { contextSize = it },
label = { Text("Context size (blank = the model's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = temperature,
onValueChange = { temperature = it },
label = { Text("Temperature (blank = llama.cpp's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
}
if (isClaude) {
if (current.models.isNotEmpty()) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
ChipGroup( ChipGroup(
label = "Model", label = "Model",
options = current.models, // The label, and the id is what is sent: for a llama model those differ,
selected = model.ifEmpty { null }, // since it is chosen by path and named by what is inside the file.
onSelect = { chosen -> model = if (model == chosen) "" else chosen }, 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(8.dp)) }
Spacer(Modifier.height(16.dp))
}
if (isCodingCli) {
// Free text as well as the chips above: the catalog is a shortcut, and a CLI will
// take a name it did not list.
OutlinedTextField( OutlinedTextField(
value = model, value = model,
onValueChange = { model = it }, onValueChange = { model = it },
@@ -234,7 +280,20 @@ fun SpawnScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
}
// Nothing is running yet, so nothing here waits for a restart -- every one of these is
// read by the process this form is about to start.
ProviderParamFields(
specs = current?.params.orEmpty(),
values = params,
onChange = { params = it },
warnAboutRestart = false,
)
// Every session whose tools act on files needs one, which is both kinds that have
// tools -- a llama session's built-in tools run in it exactly as a CLI's do.
if (takesCwd) {
OutlinedTextField( OutlinedTextField(
value = cwd, value = cwd,
onValueChange = { cwd = it }, onValueChange = { cwd = it },
@@ -244,13 +303,32 @@ fun SpawnScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
}
// Offered wherever the provider has modes, rather than where this screen believes it
// does: the server is what knows, and llama.cpp grew them without this line changing.
if (current != null && current.permissionModes.isNotEmpty()) {
ChipGroup( ChipGroup(
label = "Permissions", label = "Permissions",
options = PERMISSION_MODES, options = current.permissionModes,
selected = permissionMode, selected = permissionMode,
onSelect = { permissionMode = it }, onSelect = { permissionMode = it },
) )
Spacer(Modifier.height(16.dp))
}
if (isCodingCli) {
// Says what it does to *later* spawns as well, because it does: the level chosen here
// is stored as the default, which is the whole way that default is set. A picker that
// quietly changed a global would be the same control with the fact left out.
ChipGroup(
label = "Thinking (kept as the default for new sessions)",
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
// The CLI's own default is a level in the list, so this cannot be a one-way trip.
// Disabled-looking until the server has answered, for the reason above.
selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null,
onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } },
)
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
@@ -268,33 +346,29 @@ fun SpawnScreen(
try { try {
val spawned = val spawned =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Stored before the spawn and not after it: choosing a level is
// an intent about new sessions in general, so a spawn that then
// fails must not also lose the choice. Non-fatal for the same
// reason the fetch above is -- the session is what was asked for.
if (isCodingCli) {
runCatching { setDefaultEffort(settings, effort) }
}
spawnSession( spawnSession(
settings, settings,
// The id, not the label: labels are editable and the server // The id, not the label: labels are editable and the server
// resolves by id. Non-null here, since `chosen` came from // resolves by id. Non-null here, since `chosen` came from
// `setup`'s own provider list. // `machine`'s own provider list.
setup = setup.id, machine = machine.id,
provider = chosen.name, provider = chosen.name,
title = title.trim(), title = title.trim(),
model = model = model.trim().takeIf { offersModels },
if (isLlama) modelKey else model.trim().takeIf { isClaude }, cwd = cwd.trim().takeIf { takesCwd },
cwd = cwd.trim().takeIf { isClaude }, permissionMode = permissionMode.takeIf { it.isNotEmpty() },
permissionMode = permissionMode.takeIf { isClaude }, effort = effort.takeIf { isCodingCli },
// Sent only when set, so blank means "whatever llama.cpp does // Already only the keys somebody set: a field left blank
// by default" rather than a zero. // removes its key rather than sending an empty value, so
params = // "blank" reaches the server as "your default".
buildMap { params = params,
if (isLlama) {
contextSize
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("contextSize", it) }
temperature
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("temperature", it) }
}
},
) )
} }
onSpawned(spawned) onSpawned(spawned)
@@ -304,7 +378,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") Text(if (busy) "Spawning..." else "Spawn")
} }
@@ -0,0 +1,339 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* What a session has running beside the turn you are reading: its background tasks, then its
* subagents, in the panel [SidePanels] slides over it from the right.
*
* [active] is whether the panel is being looked at: the lists are fetched then rather than on
* composition, since the panel is composed for every session whether or not anybody opens it.
*
* [backgroundTasks] is the live count from the session's own event stream, and is what the
* background list is refetched against: a card for work that has since finished is a stale
* measurement drawn as a current one, which is the one thing a list of what is running now must not
* do.
*
* Both lists are items of one lazy column rather than two stacked scrollers, so expanding the
* background section pushes the subagents down without either being able to run off the panel.
*/
@Composable
fun SubagentPanel(
settings: ServerSettings,
summary: SessionSummary,
active: Boolean,
backgroundTasks: Int,
onClose: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
var rows by
remember(summary.id) { mutableStateOf<LoadState<List<SubagentSummary>>>(LoadState.Loading) }
var selected by remember(summary.id) { mutableStateOf(setOf<String>()) }
var deleting by remember(summary.id) { mutableStateOf(setOf<String>()) }
var deleteError by remember(summary.id) { mutableStateOf<String?>(null) }
var confirming by remember(summary.id) { mutableStateOf<List<SubagentSummary>?>(null) }
var refreshToken by remember(summary.id) { mutableIntStateOf(0) }
var background by
remember(summary.id) {
mutableStateOf<LoadState<List<BackgroundTaskSummary>?>>(LoadState.Loading)
}
var backgroundExpanded by remember(summary.id) { mutableStateOf(false) }
LaunchedEffect(active, refreshToken) {
if (!active) return@LaunchedEffect
rows = LoadState.Loading
rows =
try {
val fetched = withContext(Dispatchers.IO) { fetchSubagents(settings, summary.id) }
selected = selected intersect fetched.mapTo(mutableSetOf()) { it.id }
LoadState.Loaded(fetched)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// No reset to Loading on a refetch: the spinner belongs to the first fetch, and one flashed
// over the list at every start and end would blink precisely when something happened.
LaunchedEffect(active, backgroundTasks, refreshToken) {
if (!active || backgroundTasks == 0) return@LaunchedEffect
background =
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchBackgroundTasks(settings, summary.id) }
)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder)
Column(Modifier.fillMaxSize()) {
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
MarkButton("Close panel", onClose) { Chevron(Pointing.Right) }
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
) {
backgroundTaskSection(
count = backgroundTasks,
tasks = background,
expanded = backgroundExpanded,
onToggle = { backgroundExpanded = !backgroundExpanded },
onRetry = { refreshToken++ },
)
item(key = "subagents-heading") { PanelSectionHeading("Subagents") }
when (val state = rows) {
is LoadState.Loading ->
item(key = "subagents-loading") {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
is LoadState.Error ->
item(key = "subagents-error") {
Column {
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = { refreshToken++ }) { Text("Try again") }
}
}
is LoadState.Loaded ->
if (ordered.isNullOrEmpty()) {
item(key = "subagents-empty") {
Text(
"No subagents in this session.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
uniqueItems(ordered, key = { it.id }) { subagent ->
SubagentCard(
subagent = subagent,
selected = subagent.id in selected,
selecting = selected.isNotEmpty(),
deleting = subagent.id in deleting,
onClick = { onOpenSubagent(subagent) },
onSelect = {
selected =
if (subagent.id in selected) selected - subagent.id
else selected + subagent.id
},
)
}
}
}
}
if (selected.isNotEmpty()) {
val picked = ordered.orEmpty().filter { it.id in selected }
SubagentSelectionBar(
picked = picked,
onDelete = { confirming = picked },
modifier = Modifier.padding(horizontal = 16.dp),
)
}
deleteError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
confirming?.let { picked ->
AlertDialog(
onDismissRequest = { confirming = null },
title = {
Text(
if (picked.size == 1) "Delete this subagent?"
else "Delete ${picked.size} subagents?"
)
},
text = {
Text(
(if (picked.size == 1) "\"${picked.first().title}\"\n\n" else "") +
"A subagent's transcript is the only record of what it did: the session " +
"that started it kept just the Task call. Nothing else has a copy, so " +
"this can't be undone. The session itself is untouched."
)
},
confirmButton = {
TextButton(
onClick = {
confirming = null
selected = emptySet()
val ids = picked.map { it.id }
val gone = ids.toSet()
deleting += gone
deleteError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSubagents(settings, summary.id, ids)
gone.forEach {
transcriptCache
.session(TranscriptAddress(summary.id, it))
.purge()
}
}
val loaded = rows
if (loaded is LoadState.Loaded) {
rows =
LoadState.Loaded(loaded.value.filterNot { it.id in gone })
}
} catch (e: ApiException) {
deleteError = e.message ?: "Delete failed"
} finally {
deleting -= gone
}
}
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
)
}
}
private fun subagentOrder(rows: List<SubagentSummary>): List<SubagentSummary> =
rows.sortedWith(
compareByDescending<SubagentSummary> { it.status == "running" }
.thenByDescending { it.lastActivity }
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SubagentCard(
subagent: SubagentSummary,
selected: Boolean,
selecting: Boolean,
deleting: Boolean,
onClick: () -> Unit,
onSelect: () -> Unit,
) {
BusyItem(label = if (deleting) "deleting" else null) {
OutlinedCard(
colors =
if (selected)
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
else CardDefaults.outlinedCardColors(),
modifier =
Modifier.fillMaxWidth()
.combinedClickable(
enabled = !deleting,
onClick = { if (selecting) onSelect() else onClick() },
onLongClick = onSelect,
),
) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun SubagentSelectionBar(
picked: List<SubagentSummary>,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
val running = picked.count { it.status == "running" }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().heightIn(min = 48.dp),
) {
Text(
if (running == 0) "${picked.size} selected" else "$running still running",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDelete, enabled = running == 0) {
Text(
"Delete",
color =
if (running == 0) MaterialTheme.colorScheme.error
else LocalContentColor.current,
)
}
}
}
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@@ -134,6 +134,20 @@ val clearedColor: Color
val awaitingColor: Color val awaitingColor: Color
@Composable get() = Mocha.Peach @Composable get() = Mocha.Peach
/**
* Waiting on itself: the session's turn is over, but a subagent or a backgrounded command it
* started is still going, and it will speak again with nobody having typed anything.
*
* Its own colour rather than [awaitingColor], which is the opposite state -- that one means the
* reader has something to do, and this one means they specifically do not. Not [runningColor]
* either: nothing is being written, and a green "running" on a session that will say nothing for
* ten minutes is the wrong promise. Blue for the same reason [commandColor] is blue -- not stuck,
* but not replying to you either -- and a different blue because that one is the session acting on
* itself rather than getting on with what was asked.
*/
val waitingColor: Color
@Composable get() = Mocha.Sky
/** Approaching a limit -- still fine, worth seeing. */ /** Approaching a limit -- still fine, worth seeing. */
val warningColor: Color val warningColor: Color
@Composable get() = Mocha.Yellow @Composable get() = Mocha.Yellow
@@ -199,6 +213,8 @@ val rawSurface: Color
*/ */
fun catppuccinSyntax(): SyntaxPalette = fun catppuccinSyntax(): SyntaxPalette =
SyntaxPalette( SyntaxPalette(
addition = Mocha.Green,
deletion = Mocha.Red,
keyword = Mocha.Mauve, keyword = Mocha.Mauve,
string = Mocha.Green, string = Mocha.Green,
literal = Mocha.Peach, literal = Mocha.Peach,
@@ -0,0 +1,79 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* A model's working, shut until somebody asks for it.
*
* Shut by default, like a tool call and a memory note and for the same reason: it is not what the
* session said, and left open it puts the reasoning between the question and the answer -- which on
* a small model is most of the conversation.
*
* The heading is the whole of what the reader gets for free, so it carries the one thing worth
* knowing without opening anything: whether this is still going, and if not how long it took. A
* spinner while it runs, because that is the same fact a running command reports and it is drawn
* the same way here.
*/
@Composable
fun ThinkingCard(
item: TranscriptItem.ThinkingRow,
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(thinkingHeadline(item), style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.width(8.dp))
if (item.open) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
}
}
if (expanded) {
// Plain text rather than markdown: this is a model talking to itself, so its
// half-finished lists and stray backticks are not markup it meant to write, and
// rendering them as such makes the working look like an answer.
Text(
item.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
/**
* "Thinking", "Thought for 12.4s", or "Thought".
*
* The third is the one worth keeping: a block whose turn ended before the model said anything --
* interrupted, stopped, a process that exited -- was thought about for a length of time nobody
* measured. Naming a span there would be this screen inventing one, and the reader has no way to
* tell an invented one from the rest.
*/
fun thinkingHeadline(item: TranscriptItem.ThinkingRow): String =
when {
item.open -> "Thinking"
item.ms != null -> "Thought for ${formatMillis(item.ms)}"
else -> "Thought"
}
@@ -1,9 +1,6 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -51,24 +48,31 @@ data class ToolInput(
private val SUBJECTS: Map<String, Pair<String, Language?>> = private val SUBJECTS: Map<String, Pair<String, Language?>> =
mapOf( mapOf(
"Bash" to ("command" to Language.SHELL), "Bash" to ("command" to Language.SHELL),
"Shell" to ("command" to Language.SHELL),
"Patch" to ("diff" to Language.DIFF),
"Read" to ("file_path" to null), "Read" to ("file_path" to null),
"Write" to ("file_path" to null), "Write" to ("file_path" to null),
"Edit" to ("file_path" to null), "Edit" to ("file_path" to null),
"Glob" to ("pattern" to null), "Glob" to ("pattern" to null),
"Grep" to ("pattern" to null), "Grep" to ("pattern" to null),
"WebFetch" to ("url" to null), "WebFetch" to ("url" to null),
"WebSearch" to ("query" to null),
// Persisted transcripts keep the provider vocabulary they were written with.
"web_search" to ("query" to null),
) )
/** Fields that are the tool's own prose about itself rather than input to it. */ /** Fields that are the tool's own prose about itself rather than input to it. */
private val DESCRIPTIONS = listOf("description", "prompt") private val DESCRIPTIONS = listOf("description", "prompt")
fun parseToolInput(tool: String, input: String): ToolInput { fun parseToolInput(tool: String, input: String): ToolInput {
if (input.trim() == "null") return ToolInput(null, null, null, null, emptyList())
val json = val json =
try { try {
JSONObject(input) JSONObject(input)
} catch (_: org.json.JSONException) { } catch (_: org.json.JSONException) {
// Not an object: older transcripts and some tools send a bare string. It is still the // Not an object: older transcripts and some tools send a bare string. It is still the
// input, so it is still shown. // 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( return ToolInput(
null, null,
null, null,
@@ -78,15 +82,20 @@ fun parseToolInput(tool: String, input: String): ToolInput {
) )
} }
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null) val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
val subject = 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 { 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 = val rest =
json json
.keys() .keys()
.asSequence() .asSequence()
.filterNot(json::isNull)
.filter { it != subjectKey || subject == null } .filter { it != subjectKey || subject == null }
.filter { it !in DESCRIPTIONS || description == null } .filter { it !in DESCRIPTIONS || description == null }
.filter { it != "timeout" || timeout == null } .filter { it != "timeout" || timeout == null }
@@ -96,6 +105,31 @@ fun parseToolInput(tool: String, input: String): ToolInput {
return ToolInput(subject, language, description, timeout, rest) return ToolInput(subject, language, description, timeout, rest)
} }
private fun JSONObject.text(key: String): String? =
if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
/**
* Removes Codex's rendered Bash argv from old transcript rows.
*
* New events arrive normalized by the server, but persisted transcripts keep the input originally
* written to them. Only the outer pair are presentation quoting: quotes inside the command belong
* to the command and must not be parsed as an early end delimiter.
*/
internal fun renderedBashScript(command: String): String? {
val prefix =
listOf("/usr/bin/bash -lc ", "/bin/bash -lc ", "bash -lc ").firstOrNull {
command.startsWith(it)
} ?: return null
val quoted = command.removePrefix(prefix)
return quoted
.takeIf {
it.length >= 2 &&
((it.startsWith('\'') && it.endsWith('\'')) ||
(it.startsWith('"') && it.endsWith('"')))
}
?.substring(1, quoted.lastIndex)
}
/** /**
* A tool call's input: its subject highlighted, then whatever else it carried. * A tool call's input: its subject highlighted, then whatever else it carried.
* *
@@ -113,7 +147,8 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
RawBlock(modifier) { RawBlock(modifier) {
parsed.subject?.let { subject -> parsed.subject?.let { subject ->
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the // Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely. // one being read closely. The sideways scroll that makes that readable is the block's,
// shared with the lines below -- see [RawBlock].
Text( Text(
// Not cached: a tool's subject is one command line, which lexes in microseconds -- // Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it. // the cache exists for a fence with two hundred lines in it.
@@ -121,7 +156,6 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = false, softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) )
} }
parsed.rest.forEach { parsed.rest.forEach {
@@ -130,6 +164,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
} }
@@ -27,6 +27,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -61,7 +64,9 @@ sealed class TranscriptRow {
* *
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number, * A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
* and it is the *same* value whether the run is drawn as one card or as a group. Which value * and it is the *same* value whether the run is drawn as one card or as a group. Which value
* that is belongs to the item ([TranscriptItem.key]), not to a `when` here. * 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 abstract val key: Any
@@ -75,23 +80,15 @@ sealed class TranscriptRow {
*/ */
abstract val startSeq: Long abstract val startSeq: Long
data class Single(val item: TranscriptItem) : TranscriptRow() { data class Single(val item: TranscriptItem, override val key: Any = item.key) :
override val key: Any TranscriptRow() {
get() = item.key
override val startSeq: Long override val startSeq: Long
get() = item.seq get() = item.seq
} }
/** Two or more calls with nothing between them; drawn as one collapsed card. */ /** Two or more calls with nothing between them; drawn as one collapsed card. */
data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() { data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) :
/** The run's own name, which every call in it already carries. */ TranscriptRow() {
val id: String
get() = calls.first().runId
override val key: Any
get() = id
override val startSeq: Long override val startSeq: Long
get() = calls.first().seq get() = calls.first().seq
} }
@@ -102,33 +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, * A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words,
* and the run this exists for is the burst of five greps nobody wants to scroll past. * and the run this exists for is the burst of five greps nobody wants to scroll past.
*
* The last call is left alone too, and so is one still running wherever in its run it sits. What
* the session is doing, or did last, is the one thing worth seeing without opening anything, and a
* heading counting it hides it. What folds a call back into its run is therefore not finishing but
* being overtaken: anything arriving behind it, a reply included, makes it history.
*
* [heldOut] is the one thing being read can change, and only in that direction: a call standing on
* its own that somebody is reading is not overtaken while they read it. Opening a call *already*
* inside a group does not pull it out (2026-09-16, after it briefly did) -- it is visible where it
* is, and grouping is what gives a row its identity, so a rule that reads the open set both ways
* makes the reader's own tap rebuild the rows around it: three rows became one the moment a call
* was closed, and no anchor survives a row that no longer exists -- the list jumped by 450px and
* took the closed card with it. Which calls are held out is [SessionScreen]'s to say, since being
* inside a group once is what settles it.
*/ */
fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> = fun groupToolRuns(
DebugStats.timed("grouped tool runs") { groupRuns(items) } 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>() val rows = mutableListOf<TranscriptRow>()
var run = mutableListOf<TranscriptItem.ToolRun>() var run = mutableListOf<TranscriptItem.ToolRun>()
// A run can occupy more than one non-adjacent piece, so claimed keys span the whole transcript
// rather than resetting at each piece.
var runId: String? = null
val claimedKeys = mutableSetOf<String>()
fun flush() { fun flush() {
when (run.size) { val first = run.firstOrNull() ?: return
0 -> {} // The first piece keeps the run's name, which survives a page landing in front of it
1 -> rows += TranscriptRow.Single(run.first()) // ([adoptRun]). Later pieces qualify that name with their first call; the suffix is the
else -> rows += TranscriptRow.Tools(run.toList()) // 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() 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. // Grouped by the run each call says it belongs to, not by adjacency worked out here.
// Adjacency is the same answer most of the time and a worse one at the edges: a call // Adjacency is the same answer most of the time and a worse one at the edges: a call
// arriving next to an existing run, or a page of history arriving in front of one, both // arriving next to an existing run, or a page of history arriving in front of one, both
// change which call is *first*. // change which call is *first*.
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) { val call = item as? TranscriptItem.ToolRun
run += item if (call == null || call.runId != runId) {
} else {
flush() 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() flush()
@@ -160,7 +199,15 @@ fun ToolGroup(
*/ */
onToggle: () -> Unit, onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean, isToolExpanded: (String) -> Boolean,
onToolToggle: (String) -> Unit, /**
* Toggles one call, and says where in the group it was drawn: how far down the group's own top
* edge its card begins, and how tall that card is now.
*
* The screen anchors on *rows*, and a call is not one -- but what the reader is opening or
* shutting is the call, and keeping it under their finger needs its place inside the row. Only
* the group knows that, so only the group can say it. See `SessionScreen`'s `toggleAnchored`.
*/
onToolToggle: (id: String, top: Int, height: Int) -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit, image: @Composable (String) -> Unit,
) { ) {
@@ -175,8 +222,10 @@ fun ToolGroup(
} }
return return
} }
val placed = remember { Placed() }
Column( Column(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.onPlaced { placed.top = it.positionInRoot().y }
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow) .background(MaterialTheme.colorScheme.surfaceContainerLow)
) { ) {
@@ -196,13 +245,19 @@ fun ToolGroup(
verticalArrangement = Arrangement.spacedBy(GROUP_GAP), verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
) { ) {
group.calls.forEachIndexed { index, call -> group.calls.forEachIndexed { index, call ->
val card = remember(call.id) { Placed() }
ToolCard( ToolCard(
tool = call, tool = call,
expanded = isToolExpanded(call.id), expanded = isToolExpanded(call.id),
onToggle = { onToolToggle(call.id) }, onToggle = {
onToolToggle(call.id, (card.top - placed.top).toInt(), card.height)
},
onAnswer = onAnswer, onAnswer = onAnswer,
image = image, image = image,
shape = connectedShape(index, group.calls.size), shape = connectedShape(index, group.calls.size),
modifier =
Modifier.onPlaced { card.top = it.positionInRoot().y }
.onSizeChanged { card.height = it.height },
) )
} }
} }
@@ -212,6 +267,18 @@ fun ToolGroup(
} }
} }
/**
* Where something was last placed, in the window's coordinates, and how tall it was.
*
* Deliberately not snapshot state: it is written from the layout phase, and a write there that
* composition reads would schedule another recomposition of every group on screen, every frame.
* Nothing reads it except the gesture that follows.
*/
private class Placed {
var top = 0f
var height = 0
}
/** /**
* The height of a group's heading, and so of the bar at its foot. * The height of a group's heading, and so of the bar at its foot.
* *
@@ -293,14 +360,17 @@ fun ToolCard(
image: @Composable (String) -> Unit = {}, image: @Composable (String) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */ /** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape, shape: Shape = CardDefaults.shape,
modifier: Modifier = Modifier,
) { ) {
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) } val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val name = toolDisplayName(tool.tool)
val output = toolDisplayOutput(tool.tool, tool.output)
val deciding = tool.asks.any { it.answers.isEmpty() } val deciding = tool.asks.any { it.answers.isEmpty() }
val open = expanded || deciding val open = expanded || deciding
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) { Card(modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
Column(Modifier.padding(GROUP_INSET_LARGE)) { Column(Modifier.padding(GROUP_INSET_LARGE)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(tool.tool, style = MaterialTheme.typography.titleSmall) Text(name, style = MaterialTheme.typography.titleSmall)
if (open) { if (open) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
parsed.timeout?.let { parsed.timeout?.let {
@@ -355,24 +425,26 @@ fun ToolCard(
if (tool.tool != ASK_USER_QUESTION) { if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp)) ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
} }
if (tool.output.isNotEmpty()) { if (output.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall) Text("Output", style = MaterialTheme.typography.labelSmall)
// What the tool printed, on the surface everything verbatim gets and in the // What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is // face it was written for: this is column-aligned far more often than it is
// prose, and a proportional font silently destroys the alignment that carried // prose, and a proportional font silently destroys the alignment that carried
// the meaning. // 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: colour is // Its terminal styling applied and the rest of the escapes taken out: colour is
// often the whole of what a diff or a test run is saying. Remembered against // often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once. // the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() } val palette = remember { ansiPalette() }
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) } val styled = remember(output, palette) { ansiStyled(output, palette) }
RawBlock(Modifier.padding(top = 2.dp)) { RawBlock(Modifier.padding(top = 2.dp)) {
Text( Text(
styled, styled,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = false,
) )
} }
} }
@@ -391,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. * The permission ask on the call it is about.
* *
@@ -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"
}
@@ -35,8 +35,15 @@ class TranscriptCache(
private val root: File, private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) }, private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) { ) {
/** The cache for one session, whether or not anything has been stored for it yet. */ /**
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 * Deletes every session directory not in [ids], called after a successful list fetch. The path
@@ -63,6 +63,45 @@ sealed class TranscriptItem {
* inside that reply would step the list under them. * inside that reply would step the list under them.
*/ */
val settled: Boolean = false, val settled: Boolean = false,
/** A final value that supersedes provisional deltas behind a page boundary. */
val replacesPrefix: Boolean = false,
/**
* When the reply was sent, in epoch seconds: the time on its newest delta, which is the
* moment it finished rather than the moment it started.
*
* The transcript's own timestamp rather than a clock read here, so every device draws the
* same time under the same reply and a replayed page agrees with the live stream.
*/
val ts: Double = 0.0,
/**
* How fast it was generated, where the provider measured it; null everywhere else.
*
* Folded on from the turn's usage event rather than carried by the text, because it is not
* known until the reply is over.
*/
val tokensPerSecond: Double? = null,
/** How long the provider spent reading the prompt, where it measured that. */
val prefillMs: Long? = null,
) : TranscriptItem()
/**
* The model's working before -- or between -- the things it said.
*
* Its own row rather than part of the reply, and deliberately not a [ToolRun]: a run of tool
* calls collapses into one card, and folding a model's reasoning into "Called 6 tools" would
* file it as one of them. Shut by default, like every other card that is not what was said.
*
* Three states, because two of them are not the same absence. [open] is a block still being
* thought, which is what the spinner is for. A closed one with an [ms] says how long it took; a
* closed one without is a block whose turn ended before anything said -- an interrupted reply,
* a session stopped mid-thought -- and it says so by not naming a duration rather than by
* naming a wrong one.
*/
data class ThinkingRow(
override val seq: Long,
val text: String,
val ms: Long? = null,
val open: Boolean = true,
) : TranscriptItem() ) : TranscriptItem()
data class ToolRun( data class ToolRun(
@@ -143,6 +182,29 @@ sealed class TranscriptItem {
get() = arrived get() = arrived
} }
/**
* Where one turn ended and the next began with nothing said in between.
*
* A rule and no words. Two replies meet like this whenever a turn starts without anybody typing
* -- a subagent reporting back, a session the CLI picked up by itself -- and drawn with only
* the ordinary gap between them they read as one answer with a paragraph break through the
* middle of it. What the reader needs is to see that these are two; what started the turn is
* somebody else's transcript's business, and a row per background task is a screenful of
* dividers about work nobody was asking after.
*
* Made by the fold rather than sent by the server, because it is not something that happened:
* it is the boundary between two things that did. See [foldEvent].
*/
data class TurnBreak(override val seq: Long) : TranscriptItem() {
/**
* Its own key, because it shares a [seq] with the reply it sits above -- that reply's first
* delta is the event this was made at, and a keyed list refuses two items with one key by
* taking the app down.
*/
override val key: Any
get() = "break$seq"
}
/** /**
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather * A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
* than only shown while it waits, because it explains what follows: a conversation that * than only shown while it waits, because it explains what follows: a conversation that
@@ -175,6 +237,18 @@ sealed class TranscriptItem {
val preTokens: Long?, val preTokens: Long?,
val postTokens: Long?, val postTokens: Long?,
) : TranscriptItem() ) : TranscriptItem()
/**
* The account ran out of quota, so the turn stopped here.
*
* A divider rather than an error: nothing failed, and what a reader scrolling back needs from
* it is the same thing a clear or a compaction gives them -- why the conversation stops at this
* line.
*
* [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the
* row has words for rather than a time it invents.
*/
data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem()
} }
/** /**
@@ -219,7 +293,7 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
* with the seam wherever the reader happened to have paged. * with the seam wherever the reader happened to have paged.
*/ */
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> { fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later) val (older, newer) = healSplitThinking(healSplitMessage(earlier, later))
val startedEarlier = val startedEarlier =
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id } older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
val endedLater = val endedLater =
@@ -249,9 +323,10 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
/** /**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate. * Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
* *
* [foldEvent] never leaves two assistant messages next to each other inside one page, so two * [foldEvent] never leaves an *unfinished* assistant message with another behind it inside one
* meeting at a join are always the two halves of one reply, and leaving them apart drew a single * page, so an unsettled one at a join is always the far half of the reply the boundary cut, and
* answer as two with a paragraph break through the middle of a sentence. * 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 grows by what the older * The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is * half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
@@ -266,6 +341,36 @@ private fun healSplitMessage(
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) { if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
return earlier to later return earlier to later
} }
// A settled reply is a whole turn, so the two are two answers that happen to meet at the
// boundary rather than one cut in half -- the same distinction the fold makes, and joining them
// here would put back exactly the run-together paragraph it stops.
// The rule between them is put in here too, since the fold that would have made it never saw
// these two side by side.
if (head.settled) return earlier to (listOf(TranscriptItem.TurnBreak(tail.seq)) + later)
if (tail.replacesPrefix) return earlier.dropLast(1) to later
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
}
/**
* Rejoins a thinking block the page boundary cut, the same way [healSplitMessage] rejoins a reply.
*
* A block streams a fragment at a time exactly as a reply does, so a boundary lands inside one as
* readily. The older half then holds an open block whose [SessionEvent.ThinkingDone] is on the
* newer page -- so it spun for the rest of the conversation, saying the machine was working on a
* thought it finished minutes ago, and the same working was drawn as two blocks.
*
* Only where the older half is still open: a closed one has its own ending and the two are two
* blocks that happen to meet here. The newer half keeps its identity, for the reason [adoptRun]
* gives -- it is the part already on screen.
*/
private fun healSplitThinking(
pages: Pair<List<TranscriptItem>, List<TranscriptItem>>
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
val (earlier, later) = pages
val head = earlier.lastOrNull()
val tail = later.firstOrNull()
if (head !is TranscriptItem.ThinkingRow || tail !is TranscriptItem.ThinkingRow) return pages
if (!head.open) return pages
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1)) return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
} }
@@ -352,14 +457,64 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// first of them: a row whose identity changed with every delta would be a new row on // first of them: a row whose identity changed with every delta would be a new row on
// every frame, and the list would jump for the whole of a streamed answer. // every frame, and the list would jump for the whole of a streamed answer.
val last = items.lastOrNull() val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg) { // Only into a reply that is still arriving. A settled one is a turn that ended, and
// A message growing again is not finished, whatever a status said in between. // text after it belongs to the next turn -- a separate message, drawn as its own row.
items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false) // 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 { } 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 -> is SessionEvent.ToolStart ->
// A call id names one call for its whole lifetime. Codex can repeat the start while
// recovering an in-flight item; appending that replay made two rows with one key, and
// Compose aborts the entire LazyColumn when it encounters them. Ignoring the replay
// also repairs transcripts which already contain it when they are folded on reopen.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
items
} else {
items + items +
TranscriptItem.ToolRun( TranscriptItem.ToolRun(
entry.seq, entry.seq,
@@ -370,6 +525,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
"", "",
done = false, done = false,
) )
}
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) } is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd -> is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A fold that only ever // Created when its start is not here, rather than dropped. A fold that only ever
@@ -444,7 +600,10 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// nothing it belongs above. // nothing it belongs above.
is SessionEvent.MessageDropped -> items is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items is SessionEvent.Settings -> items
is SessionEvent.BackgroundTasks -> items
is SessionEvent.Status -> settleReply(items, event.state) is SessionEvent.Status -> settleReply(items, event.state)
is SessionEvent.AuthenticationRequired ->
items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image -> is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of its own when there is // Under the call that produced it when there is one, and a row of its own when there is
@@ -458,12 +617,32 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
} else { } else {
items + TranscriptItem.ImageItem(entry.seq, event.ref) items + TranscriptItem.ImageItem(entry.seq, event.ref)
} }
is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt)
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq) is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted -> is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens) items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]") is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// Screen-level state, not transcript rows -- see SessionScreen. // Said rather than skipped: a line the server could not read is a hole in the conversation,
is SessionEvent.UsageDelta -> items // 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
} }
/** /**
@@ -474,9 +653,22 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
*/ */
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> { private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
if (sessionWorking(state)) return items if (sessionWorking(state)) return items
val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items // A block the turn ended in the middle of is over, however it ended. Left open it spins for
if (last.settled) return items // the rest of the conversation, which says the machine is working when nothing is.
return items.dropLast(1) + last.copy(settled = true) 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( private fun updateTool(
@@ -1,6 +1,7 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -10,6 +11,9 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.text.selection.SelectionState import androidx.compose.foundation.text.selection.SelectionState
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -49,6 +53,8 @@ fun TranscriptList(
units: List<TranscriptUnit>, units: List<TranscriptUnit>,
state: LazyListState, state: LazyListState,
moreHistory: Boolean, moreHistory: Boolean,
historyError: String?,
onRetryHistory: () -> Unit,
selection: SelectionState, selection: SelectionState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
below: @Composable () -> Unit, below: @Composable () -> Unit,
@@ -94,15 +100,29 @@ fun TranscriptList(
DebugStats.count("unit composed") DebugStats.count("unit composed")
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) } Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
} }
// Standing in for everything not fetched yet. Only here while there is more -- its // Standing in for everything not fetched yet. A failed fetch stays actionable here:
// appearance at the top edge is also roughly when the next page is asked for, so what // when the loaded transcript is too short to scroll, this boundary is the only place
// it reports is a fetch in flight rather than an end reached. // the reader can be given another way to ask.
if (moreHistory) { if (moreHistory) {
item(key = "history", contentType = "history") { item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) { Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
if (historyError == null) {
CircularProgressIndicator( CircularProgressIndicator(
Modifier.align(Alignment.Center).size(HISTORY_SPINNER) Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
) )
} else {
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Couldn't load earlier messages. $historyError",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = onRetryHistory) { Text("Try again") }
}
}
} }
} }
} }
@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicReference
*/ */
class TranscriptSource( class TranscriptSource(
private val settings: ServerSettings, private val settings: ServerSettings,
private val sessionId: String, private val address: TranscriptAddress,
val cache: SessionCache, val cache: SessionCache,
) { ) {
private val stream = AtomicReference<EventStream?>(null) private val stream = AtomicReference<EventStream?>(null)
@@ -65,7 +65,7 @@ class TranscriptSource(
val tail = cache.tail() ?: return false val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at* // `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there. // the cursor when the server still has one there.
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1) val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
val matches = val matches =
answer.size == 1 && answer.size == 1 &&
try { try {
@@ -83,7 +83,7 @@ class TranscriptSource(
*/ */
suspend fun fetchOpening(): List<SeqEvent> { suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server") DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW) val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) } page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush() cache.flush()
return page.map { it.second } return page.map { it.second }
@@ -108,7 +108,7 @@ class TranscriptSource(
val page = val page =
fetchTranscript( fetchTranscript(
settings, settings,
sessionId, address,
before = before, before = before,
limit = limit, limit = limit,
coalesce = coalesce, coalesce = coalesce,
@@ -131,7 +131,7 @@ class TranscriptSource(
* well lose. * well lose.
*/ */
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, sessionId) val opened = EventStream(settings, address)
stream.getAndSet(opened)?.close() stream.getAndSet(opened)?.close()
try { try {
opened.run(after, onOpen, onReset) { raw, entry -> opened.run(after, onOpen, onReset) { raw, entry ->
@@ -130,6 +130,25 @@ sealed class TranscriptUnit {
get() = "u$seq:$ordinal" get() = "u$seq:$ordinal"
} }
/**
* The line under a finished reply: when it was sent, and how fast it was generated.
*
* A unit of its own rather than something drawn inside the last block, because a settled reply
* *is* its blocks -- there is no row left to hang it on, and the last block is a piece of
* markdown that knows nothing about the message it came from.
*/
data class ReplyFoot(
override val seq: Long,
override val ordinal: Int,
val ts: Double,
val tokensPerSecond: Double?,
val prefillMs: Long?,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "f$seq"
}
/** One memory note of a settled reply; see [MemoryNote]. */ /** One memory note of a settled reply; see [MemoryNote]. */
data class Memory( data class Memory(
override val seq: Long, override val seq: Long,
@@ -237,6 +256,19 @@ fun transcriptUnits(
} }
} }
} }
// Unconditional, because being in this branch is what says the reply is over:
// [splitWanted] is settled-or-overtaken. The case to keep out is a message still
// arriving, whose "sent at" is not yet the one it ends up with, and that is drawn
// whole.
units +=
TranscriptUnit.ReplyFoot(
row.startSeq,
ordinal,
item.ts,
item.tokensPerSecond,
item.prefillMs,
gap(FOOT_SPACING),
)
} else { } else {
units += TranscriptUnit.Whole(row, rowGap) units += TranscriptUnit.Whole(row, rowGap)
} }
@@ -280,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 * length has lines that wrap, so its bubble is at the full width already and the slices match it
* exactly. Below it, one item of at most a few screens is nothing the list minds composing. * exactly. Below it, one item of at most a few screens is nothing the list minds composing.
*/ */
/**
* The room between a reply's last block and the line under it.
*
* Tighter than the gap between blocks: the footer belongs to the message above it, and at a block's
* spacing it reads as a row of its own floating between two replies.
*/
private val FOOT_SPACING: Dp = 2.dp
const val USER_SPLIT_CHARS = 4000 const val USER_SPLIT_CHARS = 4000
/** /**
@@ -375,6 +415,7 @@ private val TranscriptUnit?.kind: String
is TranscriptUnit.PeerBlock -> "peer block" is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice" is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note" is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.ReplyFoot -> "reply footer"
is TranscriptUnit.Whole -> is TranscriptUnit.Whole ->
when (val row = row) { when (val row = row) {
is TranscriptRow.Tools -> "tool group" is TranscriptRow.Tools -> "tool group"
@@ -9,12 +9,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -30,7 +33,14 @@ import java.time.OffsetDateTime
* own, so the only thing its Back could ever have meant was "put this away". * own, so the only thing its Back could ever have meant was "put this away".
*/ */
@Composable @Composable
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) { 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 // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
// between its title, content and buttons at sizes meant for a sentence of prose and a decision; // between its title, content and buttons at sizes meant for a sentence of prose and a decision;
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was // this is a dense read-out, and those gaps left a band of empty dialog above Close that was
@@ -45,10 +55,6 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
// Deliberately not subtitled with the provider this was opened from. These
// numbers belong to an account on a particular machine -- naming the session's
// provider here made an echo session's screen read "echo" above a line reading
// "claude". Each machine names itself and the service it came from.
Text( Text(
"Usage", "Usage",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
@@ -65,10 +71,24 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Scrolls rather than being trimmed: a machine can report any number of windows and // Scrolls rather than being trimmed: a machine can report any number of windows and
// there can be any number of machines, and a dialog is the one place where running // a provider can report several billing pools, and a dialog is the one place where
// out of room is silent. `fill = false` so a short read-out keeps a short dialog. // running out of room is silent. `fill = false` so a short read-out keeps a short
// dialog.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { 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)) { TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
Text("Close") Text("Close")
@@ -76,29 +96,46 @@ 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. */ /** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
@Composable @Composable
private fun UsageBody(state: LoadState<List<UsageSnapshot>>) { private fun UsageBody(
state: LoadState<List<UsageSnapshot>>,
now: OffsetDateTime,
onSignIn: () -> Unit,
) {
Column { Column {
when (val current = state) { when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> is LoadState.Loaded ->
if (current.value.isEmpty()) { if (current.value.isEmpty()) {
// Not an error and not a blank screen: no machine offers a paid service, so // 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. // there is genuinely nothing to report and saying so is the answer.
Text( Text(
"No machine here runs anything with usage limits.", "This session's provider has no usage limits.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
// No card around each machine. A card is a step up the surface ladder, and // No card around each pool. A card is a step up the surface ladder, and inside
// inside a dialog -- itself a raised surface -- the step barely renders while // a dialog -- itself a raised surface -- the step barely renders while costing
// costing 16dp on every side. What separates one machine from the next is the // 16dp on every side. What separates one pool from the next is the line naming
// line naming it. // it.
current.value.forEachIndexed { index, snapshot -> current.value.forEachIndexed { index, snapshot ->
if (index > 0) { if (index > 0) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
@@ -108,18 +145,18 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
// read as a section of their own. Small and quiet, because the numbers // read as a section of their own. Small and quiet, because the numbers
// below are what somebody opened this to see. // below are what somebody opened this to see.
Text( Text(
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}", usageSectionTitle(snapshot),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
SnapshotState(snapshot) SnapshotState(snapshot, onSignIn)
snapshot.windows.forEachIndexed { windowIndex, window -> snapshot.windows.forEachIndexed { windowIndex, window ->
// Between the bars, not after the last one: a trailing gap here is what // Between the bars, not after the last one: a trailing gap here is what
// put a band of empty dialog above the Close button. // put a band of empty dialog above the Close button.
if (windowIndex > 0) { if (windowIndex > 0) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
WindowBar(window) WindowBar(window, now)
} }
} }
} }
@@ -127,20 +164,44 @@ 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. * Anything other than numbers: why this machine has none.
* *
* The distinction the old single message could not draw. A machine nobody has logged in on is * The distinction the old single message could not draw. A machine nobody has logged in on is
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be * working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
* the interface nagging about a decision already made. 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 @Composable
private fun SnapshotState(snapshot: UsageSnapshot) { private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
when (snapshot.state) { when (snapshot.state) {
"ok" -> {} "ok" -> {}
"notLoggedIn" -> "notLoggedIn",
"loginRequired" -> {
Text( Text(
"No Claude account on this machine.", snapshot.detail ?: "No Claude account on this machine.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onSignIn) { Text("Sign in") }
}
"authenticating" ->
Text(
"Claude sign-in is in progress.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -162,7 +223,7 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
} }
@Composable @Composable
private fun WindowBar(window: UsageWindow) { private fun WindowBar(window: UsageWindow, now: OffsetDateTime) {
Column { Column {
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
Text( Text(
@@ -173,12 +234,8 @@ private fun WindowBar(window: UsageWindow) {
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium) Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
LinearProgressIndicator( UsageProgressIndicator(window, now, Modifier.fillMaxWidth())
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) }, resetLine(window, now)?.let {
color = quotaColor(window.percent),
modifier = Modifier.fillMaxWidth(),
)
resetLine(window)?.let {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
it, it,
@@ -197,8 +254,8 @@ private fun WindowBar(window: UsageWindow) {
* failure appeared as an ISO string in a sentence written for a person. Both are named in * failure appeared as an ISO string in a sentence written for a person. Both are named in
* [WindowEnd], and the session bar words them the same way. * [WindowEnd], and the session bar words them the same way.
*/ */
private fun resetLine(window: UsageWindow): String? = private fun resetLine(window: UsageWindow, now: OffsetDateTime): String? =
when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) { when (val end = windowEnd(window.resetsAt, now)) {
WindowEnd.NotRunning -> null WindowEnd.NotRunning -> null
WindowEnd.Unreadable -> "reset time unreadable" WindowEnd.Unreadable -> "reset time unreadable"
is WindowEnd.Ends -> is WindowEnd.Ends ->
Binary file not shown.
@@ -0,0 +1,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) assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text)
} }
@Test
fun `a diff colours changes and identifies its framing separately`() {
val code = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n context\n+new"
assertSpans(code, Language.DIFF, Kind.DELETION, "-old")
assertSpans(code, Language.DIFF, Kind.ADDITION, "+new")
assertSpans(
code,
Language.DIFF,
Kind.METADATA,
"--- a/file",
"+++ b/file",
"@@ -1 +1 @@",
)
}
@Test @Test
fun `every language the fence table knows has a scanner`() { fun `every language the fence table knows has a scanner`() {
Language.entries.forEach { spansOf("x", it) } Language.entries.forEach { spansOf("x", it) }
@@ -0,0 +1,32 @@
package com.example.aiapp
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* What the transcript says where a session ran out of quota.
*
* The pair worth a test is the one that reads the same when it goes wrong: a reset time that
* arrived and one that never did. The second must not turn into a plausible-looking time, because a
* reader has no way of telling an invented one from a reported one.
*/
class LimitRowTest {
private val utc = ZoneId.of("UTC")
@Test
fun `a reported reset time is shown as a time`() {
// 2026-09-05T12:00:00Z. Asserted as a prefix and the clock reading rather than as the
// whole string: the platform's own short-time format is what this asks for, and it
// differs by JDK and locale down to which space character separates the meridiem.
val summary = limitSummary(1_788_609_600.0, utc)
assertTrue(summary.startsWith("Usage limit reached • resets "), summary)
assertTrue(summary.contains("12:00"), summary)
}
@Test
fun `a limit with no reset time says only what is known`() {
assertEquals("Usage limit reached", limitSummary(null, utc))
}
}
@@ -0,0 +1,31 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class MarkdownLinksTest {
@Test
fun `absolute file paths are opened on the session machine`() {
assertEquals(
"/home/bob/repos/ai app/Main.kt",
filePathOf("/home/bob/repos/ai%20app/Main.kt"),
)
assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt"))
assertEquals("/home/bob/Main.kt", filePathOf("file://localhost/home/bob/Main.kt"))
}
@Test
fun `editor coordinates select the file itself`() {
assertEquals("/home/bob/Main.kt", filePathOf("/home/bob/Main.kt:42"))
assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt:42:7#L42"))
}
@Test
fun `ordinary links keep their external meaning`() {
assertNull(filePathOf("https://example.com/source.kt"))
assertNull(filePathOf("docs/source.kt"))
assertNull(filePathOf("//example.com/source.kt"))
assertNull(filePathOf("file://example.com/source.kt"))
}
}
@@ -0,0 +1,72 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PendingMessagesTest {
private fun local(text: String = "keep this") =
QueuedMessage("local-1", text, emptyList(), local = true)
@Test
fun a_server_queue_replaces_the_local_bridge_instead_of_duplicating_it() {
val queued =
reconcileQueuedMessage(
listOf(local()),
SessionEvent.MessageQueued("server-1", "keep this", emptyList()),
)
assertEquals(1, queued.size)
assertEquals("server-1", queued.single().id)
assertTrue(!queued.single().local)
}
@Test
fun an_immediately_received_message_removes_its_local_bridge() {
val queued =
reconcileUserMessage(
listOf(local()),
SessionEvent.UserMessage("keep this", id = null, attachments = emptyList()),
)
assertTrue(queued.isEmpty())
}
@Test
fun a_transport_failure_stays_on_its_message() {
val queued = markPendingFailure(listOf(local()), "local-1", "Can't reach the server")
assertEquals("Can't reach the server", queued.single().refusal)
assertTrue(queued.single().local)
}
@Test
fun server_acceptance_keeps_the_bubble_until_the_provider_event() {
val queued = markPendingAccepted(listOf(local()), "local-1")
assertEquals(1, queued.size)
assertTrue(queued.single().serverAccepted)
}
@Test
fun discarding_a_failed_send_removes_only_that_local_copy() {
val server = QueuedMessage("server-1", "already accepted", emptyList())
val queued = listOf(local(), local("keep this one").copy(id = "local-2"), server)
val discarded = discardPendingMessage(queued, "local-1")
assertEquals(listOf("local-2", "server-1"), discarded.map { it.id })
}
@Test
fun identical_messages_are_reconciled_one_at_a_time() {
val queued = listOf(local(), local().copy(id = "local-2"))
val afterFirst =
reconcileUserMessage(
queued,
SessionEvent.UserMessage("keep this", id = null, attachments = emptyList()),
)
assertEquals(listOf("local-2"), afterFirst.map { it.id })
}
}
@@ -0,0 +1,111 @@
package com.example.aiapp
import androidx.compose.ui.geometry.Offset
import kotlin.test.Test
import kotlin.test.assertEquals
class SessionImageTest {
@Test
fun `a full-height image hides both bars`() {
assertEquals(
ViewerBars(status = true, navigation = true),
viewerBars(
imageWidth = 1000,
imageHeight = 2000,
viewportWidth = 1000,
viewportHeight = 2000,
scale = 1f,
offset = Offset.Zero,
insets = ViewerBarInsets(status = 100, navigation = 100),
),
)
}
@Test
fun `a letterboxed image leaves both bars visible`() {
assertEquals(
ViewerBars(),
viewerBars(
imageWidth = 1000,
imageHeight = 500,
viewportWidth = 1000,
viewportHeight = 2000,
scale = 1f,
offset = Offset.Zero,
insets = ViewerBarInsets(status = 100, navigation = 100),
),
)
}
@Test
fun `panning into the status bar hides only that bar`() {
assertEquals(
ViewerBars(status = true),
viewerBars(
imageWidth = 1000,
imageHeight = 500,
viewportWidth = 1000,
viewportHeight = 2000,
scale = 2f,
offset = Offset(0f, -500f),
insets = ViewerBarInsets(status = 100, navigation = 100),
),
)
}
@Test
fun `native scale reverses fitting a tall image`() {
assertEquals(
1.25f,
nativeScale(
imageWidth = 1000,
imageHeight = 2000,
viewportWidth = 1000,
viewportHeight = 1600,
),
)
}
@Test
fun `native scale leaves a small image alone`() {
assertEquals(
1f,
nativeScale(
imageWidth = 500,
imageHeight = 500,
viewportWidth = 1000,
viewportHeight = 1000,
),
)
}
@Test
fun `zoom keeps the region panned to in the center`() {
assertEquals(
Offset(240f, -160f),
zoomOffset(
offset = Offset(120f, -80f),
centroid = Offset(500f, 1000f),
pan = Offset.Zero,
oldScale = 2f,
newScale = 4f,
viewportCenter = Offset(500f, 1000f),
),
)
}
@Test
fun `zoom keeps an off-center pinch beneath moving fingers`() {
assertEquals(
Offset(460f, 420f),
zoomOffset(
offset = Offset(100f, -100f),
centroid = Offset(250f, 400f),
pan = Offset(10f, 20f),
oldScale = 2f,
newScale = 4f,
viewportCenter = Offset(500f, 1000f),
),
)
}
}
@@ -0,0 +1,137 @@
package com.example.aiapp
import java.time.OffsetDateTime
import kotlin.test.Test
import kotlin.test.assertEquals
class SessionUsageTest {
@Test
fun `usage snapshots stay with the session's machine and provider`() {
val claude = snapshot("machine", "claude", null)
val codex = snapshot("machine", "codex", "codex")
val reserve = snapshot("machine", "codex", "gpt-reserve")
val elsewhere = snapshot("other", "codex", "codex")
assertEquals(
listOf(codex, reserve),
usageSnapshotsFor(
listOf(claude, codex, reserve, elsewhere),
machine = "machine",
provider = "codex",
),
)
}
@Test
fun `a session without a meter has no usage snapshots`() {
assertEquals(
emptyList(),
usageSnapshotsFor(
listOf(snapshot("machine", "claude", null)),
machine = "machine",
provider = null,
),
)
}
@Test
fun `the model selects its named pool and other models use the generic pool`() {
val generic = snapshot("machine", "codex", "codex")
val spark =
snapshot(
"machine",
"codex",
"codex_bengalfox",
limitName = "GPT-5.3-Codex-Spark",
)
val reserve =
snapshot("machine", "codex", "base_model_inference", limitName = "gpt-reserve")
val pools = listOf(generic, spark, reserve)
assertEquals(spark, usagePoolFor(pools, "gpt-5.3-codex-spark"))
assertEquals(reserve, usagePoolFor(pools, "gpt-5.6-luna"))
assertEquals(generic, usagePoolFor(pools, "gpt-6-astra"))
}
@Test
fun `the bar uses the shortest reported cycle`() {
val weekly = window("Weekly", 10_080)
val hourly = window("5-hour window", 300)
assertEquals(hourly, shortestUsageWindow(listOf(weekly, hourly)))
assertEquals(null, shortestUsageWindow(listOf(window("unknown", null))))
}
@Test
fun `the time cursor follows elapsed time through the window`() {
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
assertEquals(
0.4f,
usageWindowElapsedFraction(
window("5-hour window", 300, "2026-09-17T15:00:00Z"),
now,
),
)
}
@Test
fun `the time cursor clamps at the window ends`() {
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
assertEquals(
0f,
usageWindowElapsedFraction(
window("5-hour window", 300, "2026-09-17T18:00:00Z"),
now,
),
)
assertEquals(
1f,
usageWindowElapsedFraction(
window("5-hour window", 300, "2026-09-17T11:00:00Z"),
now,
),
)
}
@Test
fun `the time cursor is absent without a usable duration and reset time`() {
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
assertEquals(null, usageWindowElapsedFraction(window("unknown", null), now))
assertEquals(null, usageWindowElapsedFraction(window("not running", 300), now))
assertEquals(
null,
usageWindowElapsedFraction(window("unreadable", 300, "not a timestamp"), now),
)
assertEquals(null, usageWindowElapsedFraction(window("zero", 0), now))
}
private fun snapshot(
machine: String,
provider: String,
limitId: String?,
limitName: String? = null,
) =
UsageSnapshot(
provider = provider,
machine = machine,
machineName = machine,
limitId = limitId,
limitName = limitName,
state = "ok",
detail = null,
windows = emptyList(),
)
private fun window(label: String, durationMinutes: Long?, resetsAt: String? = null) =
UsageWindow(
kind = "test",
label = label,
percent = 12.0,
durationMinutes = durationMinutes,
resetsAt = resetsAt,
active = false,
)
}
@@ -0,0 +1,150 @@
package com.example.aiapp
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The model's working as its own row, and the line under a finished reply.
*
* Both have the same shape of hazard: a state nothing measured must not come out looking like one
* that was. A block interrupted mid-thought has no duration, and a provider that reports no
* generation speed has no figure -- neither may borrow one.
*/
class ThinkingTest {
private val utc = ZoneId.of("UTC")
private var seq = 0L
private fun fold(items: List<TranscriptItem>, event: SessionEvent, ts: Double = 1.0) =
foldEvent(items, SeqEvent(seq = ++seq, ts = ts, event = event))
private fun fold(vararg events: SessionEvent) =
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
private fun thinking(items: List<TranscriptItem>) =
items.filterIsInstance<TranscriptItem.ThinkingRow>()
@Test
fun `deltas accumulate into one block that ends with its duration`() {
val items =
fold(
SessionEvent.Thinking("the user "),
SessionEvent.Thinking("wants a card"),
SessionEvent.ThinkingDone(12_400),
SessionEvent.AssistantText("Here it is."),
)
val block = thinking(items).single()
assertEquals("the user wants a card", block.text)
assertEquals(12_400, block.ms)
assertEquals("Thought for 12.4s", thinkingHeadline(block))
// Its own row, above the reply rather than inside it.
assertEquals(1, items.filterIsInstance<TranscriptItem.AssistantMsg>().size)
}
@Test
fun `a block the turn ended in the middle of stops without naming a span`() {
val items = fold(SessionEvent.Thinking("half a thought"), SessionEvent.Status("idle"))
val block = thinking(items).single()
assertNull(block.ms)
assertTrue(!block.open)
assertEquals("Thought", thinkingHeadline(block))
}
@Test
fun `a block still being thought says so`() {
val block = thinking(fold(SessionEvent.Thinking("hmm"))).single()
assertTrue(block.open)
assertEquals("Thinking", thinkingHeadline(block))
}
@Test
fun `thinking between two replies is two replies and two blocks`() {
val items =
fold(
SessionEvent.Thinking("first"),
SessionEvent.ThinkingDone(1_000),
SessionEvent.AssistantText("One."),
SessionEvent.Thinking("second"),
SessionEvent.ThinkingDone(2_000),
SessionEvent.AssistantText("Two."),
)
assertEquals(listOf("first", "second"), thinking(items).map { it.text })
assertEquals(
listOf("One.", "Two."),
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text },
)
}
@Test
fun `a reply carries when it was sent and what it cost to produce`() {
val items =
fold(emptyList(), SessionEvent.AssistantText("Done."), ts = 1_788_609_600.0).let {
fold(it, SessionEvent.UsageDelta(42, 100, 18.37, 9_489))
}
val reply = items.filterIsInstance<TranscriptItem.AssistantMsg>().single()
assertEquals(1_788_609_600.0, reply.ts)
assertEquals(18.37, reply.tokensPerSecond)
assertEquals(9_489, reply.prefillMs)
val footer = replyFooterText(reply.ts, reply.tokensPerSecond, reply.prefillMs, utc)
// The clock reading rather than the whole string: the platform's own short-time format
// differs by JDK and locale, which is the point of asking it for one.
assertTrue(footer!!.startsWith("read 9.5s · 18.4 tok/s · "), footer)
assertTrue(footer.contains("12:00"), footer)
}
@Test
fun `the clock stays at the end however much the provider measured`() {
// What a provider that measures nothing leaves: the time, and nothing in front of it.
val bare = replyFooterText(1_788_609_600.0, null, null, utc)
assertTrue(bare!!.contains("12:00"), bare)
assertTrue(!bare.contains("tok/s") && !bare.contains("read"), bare)
// Every shape ends with the same thing, which is the whole point of the order: the clock
// does not move because the session is on a provider that measures more or less.
val shapes =
listOf(
bare,
replyFooterText(1_788_609_600.0, 18.37, null, utc)!!,
replyFooterText(1_788_609_600.0, null, 9_489, utc)!!,
replyFooterText(1_788_609_600.0, 18.37, 9_489, utc)!!,
)
assertEquals(1, shapes.map { it.substringAfterLast("· ") }.distinct().size, "$shapes")
// A reply with nothing to say has no line at all rather than an empty one.
assertNull(replyFooterText(0.0, null, null, utc))
}
@Test
fun `a block cut by a page boundary is one block, and it is not still going`() {
// Each page folded on its own, as the app does: the older one holds the fragments before
// the cut and no ending, the newer one the rest and the ending.
val older = fold(SessionEvent.Thinking("half a "))
val newer = fold(SessionEvent.Thinking("thought"), SessionEvent.ThinkingDone(2_000))
val joined = joinPages(older, newer)
val block = thinking(joined).single()
assertEquals("half a thought", block.text)
assertEquals(2_000, block.ms)
assertTrue(!block.open)
}
@Test
fun `two blocks meeting at a page boundary stay two`() {
val older = fold(SessionEvent.Thinking("first"), SessionEvent.ThinkingDone(1_000))
val newer = fold(SessionEvent.Thinking("second"), SessionEvent.ThinkingDone(2_000))
assertEquals(listOf("first", "second"), thinking(joinPages(older, newer)).map { it.text })
}
@Test
fun `usage that lands after a tool call is not folded onto an older reply`() {
val items =
fold(
SessionEvent.AssistantText("Reading it."),
SessionEvent.ToolStart("t1", "Read", "{}"),
SessionEvent.ToolEnd("t1", "done"),
SessionEvent.UsageDelta(42, 100, 18.0, 500),
)
assertNull(items.filterIsInstance<TranscriptItem.AssistantMsg>().single().tokensPerSecond)
}
}
@@ -0,0 +1,43 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ToolInputTest {
@Test
fun `bash wrapper ignores double quotes inside its outer pair`() {
assertEquals(
"rg -n \"needle\" server app",
renderedBashScript("/usr/bin/bash -lc \"rg -n \"needle\" server app\""),
)
}
@Test
fun `bash wrapper ignores single quotes inside its outer pair`() {
assertEquals(
"printf 'hello'",
renderedBashScript("/bin/bash -lc 'printf 'hello''"),
)
}
@Test
fun `unquoted or unfamiliar commands stay intact`() {
assertNull(renderedBashScript("/usr/bin/bash -lc echo hello"))
assertNull(renderedBashScript("/usr/bin/fish -lc 'echo hello'"))
}
@Test
fun `missing optional input is not displayed as null`() {
assertTrue(parseToolInput("TaskOutput", "null").rest.isEmpty())
}
@Test
fun `collaboration calls say what they do and omit empty completion`() {
assertEquals("Spawn agent", toolDisplayName("Task"))
assertEquals("Wait for agents", toolDisplayName("TaskOutput"))
assertEquals("", toolDisplayOutput("TaskOutput", "completed"))
assertEquals("failed", toolDisplayOutput("TaskOutput", "failed"))
}
}
@@ -0,0 +1,167 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* How a run of tool calls is cut into rows: the call still running, the last call in the
* transcript, and one held out because the reader has it open are drawn on their own, and every
* piece the cut leaves behind still has a key of its own -- two rows sharing one key take the app
* down, and a key that moves takes the reader's place with it.
*/
class ToolRowsTest {
private var seq = 0L
private fun call(id: String, runId: String = id, done: Boolean = true) =
TranscriptItem.ToolRun(
seq = ++seq,
id = id,
runId = runId,
tool = "Bash",
input = "{}",
output = if (done) "ok" else "",
done = done,
)
/** Something that is not a tool call, to put behind the run so its last call folds in. */
private fun reply() = TranscriptItem.AssistantMsg(seq = ++seq, text = "done")
private fun shape(rows: List<TranscriptRow>) = rows.map { row ->
when (row) {
is TranscriptRow.Tools -> row.calls.map { it.id }
is TranscriptRow.Single -> listOf((row.item as? TranscriptItem.ToolRun)?.id ?: "reply")
}
}
private fun assertKeysDistinct(rows: List<TranscriptRow>) =
assertEquals(rows.size, rows.map { it.key }.toSet().size, "$rows")
@Test
fun the_call_still_running_is_a_row_of_its_own() {
val rows =
groupToolRuns(
listOf(
call("a"),
call("b", runId = "a"),
call("c", runId = "a", done = false),
call("d", runId = "a"),
reply(),
)
)
assertEquals(
listOf(listOf("a", "b"), listOf("c"), listOf("d"), listOf("reply")),
shape(rows),
)
assertKeysDistinct(rows)
}
@Test
fun a_call_running_in_the_middle_of_its_run_splits_the_group_in_two() {
val rows =
groupToolRuns(
listOf(
call("a"),
call("b", runId = "a", done = false),
call("c", runId = "a"),
call("d", runId = "a"),
reply(),
)
)
assertEquals(
listOf(listOf("a"), listOf("b"), listOf("c", "d"), listOf("reply")),
shape(rows),
)
assertKeysDistinct(rows)
}
/**
* A call held out is one the reader opened while it stood on its own; being overtaken while
* they read it does not fold it away, and closing it hands it back to its run.
*/
@Test
fun a_held_out_call_stays_out_of_its_group() {
val calls =
listOf(
call("a"),
call("b", runId = "a"),
call("c", runId = "a"),
call("d", runId = "a"),
reply(),
)
val whileHeld = groupToolRuns(calls, heldOut = setOf("d"))
val afterItCloses = groupToolRuns(calls)
assertEquals(listOf(listOf("a", "b", "c"), listOf("d"), listOf("reply")), shape(whileHeld))
assertTrue(whileHeld[1] is TranscriptRow.Single, "$whileHeld")
assertKeysDistinct(whileHeld)
assertEquals(listOf(listOf("a", "b", "c", "d"), listOf("reply")), shape(afterItCloses))
assertTrue(afterItCloses.first() is TranscriptRow.Tools, "$afterItCloses")
}
/**
* The one case where the run's name is a call that is not in the run's first row: a page of
* history joined onto a run whose own first call is still going ([joinPages] renames the older
* calls to the newer run's name). Both rows would key on that name.
*/
@Test
fun the_run_keeps_its_name_even_when_the_call_it_is_named_after_is_the_one_running() {
val rows = groupToolRuns(listOf(call("a", runId = "b"), call("b", done = false)))
assertEquals(listOf(listOf("a"), listOf("b")), shape(rows))
assertKeysDistinct(rows)
assertEquals("b", rows.first().key)
}
@Test
fun a_run_that_reappears_after_another_row_keeps_distinct_keys() {
val rows =
groupToolRuns(
listOf(
call("older", runId = "exec-1"),
call("older-2", runId = "exec-1"),
reply(),
call("exec-1", runId = "exec-1"),
call("newer", runId = "exec-1"),
reply(),
)
)
assertTrue(rows[0] is TranscriptRow.Tools, "$rows")
assertTrue(rows[2] is TranscriptRow.Tools, "$rows")
assertKeysDistinct(rows)
assertEquals("exec-1", rows[0].key)
assertEquals("exec-1/exec-1", rows[2].key)
}
/**
* Finishing is not what folds a call back in -- being overtaken is. A session that has run its
* last command and is writing its reply leaves that command standing until the reply starts.
*/
@Test
fun the_last_call_stays_out_when_it_finishes_and_folds_in_when_something_follows() {
val a = call("a")
val running = call("b", runId = "a", done = false)
val finished = running.copy(done = true)
val whileRunning = groupToolRuns(listOf(a, running))
val afterItEnds = groupToolRuns(listOf(a, finished))
val afterTheReply = groupToolRuns(listOf(a, finished, reply()))
assertEquals(listOf(listOf("a"), listOf("b")), shape(whileRunning))
assertEquals(listOf(listOf("a"), listOf("b")), shape(afterItEnds))
assertEquals(listOf(listOf("a", "b"), listOf("reply")), shape(afterTheReply))
// The run keeps the key it was drawn under throughout, so the list rebuilds a row rather
// than losing its anchor.
assertEquals(whileRunning.first().key, afterItEnds.first().key)
assertEquals(whileRunning.first().key, afterTheReply.first().key)
}
@Test
fun a_run_of_finished_calls_is_one_group_once_something_follows_it() {
val rows =
groupToolRuns(
listOf(call("a"), call("b", runId = "a"), call("c", runId = "a"), reply())
)
assertEquals(listOf(listOf("a", "b", "c"), listOf("reply")), shape(rows))
assertTrue(rows.first() is TranscriptRow.Tools, "$rows")
}
}
@@ -23,7 +23,7 @@ class TranscriptCacheTest {
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it } private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
private fun session(id: String = "s") = cache().session(id) private fun session(id: String = "s") = cache().session(TranscriptAddress(id))
private fun line(seq: Long, type: String = "toolStart") = private fun line(seq: Long, type: String = "toolStart") =
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}""" """{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
@@ -0,0 +1,185 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Where one turn ends and the next begins, which is the part of the fold that had no way of saying
* anything was wrong: two replies run together read as one long answer, and the seam is somewhere
* in the middle of a sentence.
*/
class TranscriptItemsTest {
private var seq = 0L
private fun fold(items: List<TranscriptItem>, event: SessionEvent) =
foldEvent(items, SeqEvent(seq = ++seq, ts = 1.0, event = event))
private fun fold(vararg events: SessionEvent) =
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
private fun texts(items: List<TranscriptItem>) =
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text }
@Test
fun an_authentication_failure_stays_visible_as_an_error_row() {
val entry =
SeqEvent(
seq = 7,
ts = 1.0,
event = SessionEvent.AuthenticationRequired("sign in again"),
)
assertEquals(
TranscriptItem.ErrorMsg(7, "sign in again"),
foldEvent(emptyList(), entry).single(),
)
}
@Test
fun text_after_the_turn_ended_is_a_new_reply_rather_than_more_of_the_last_one() {
val items =
fold(
SessionEvent.AssistantText("You'll get the one-line notice when it lands."),
SessionEvent.Status("idle"),
SessionEvent.AssistantText("Dev Updater fix is pushed."),
)
assertEquals(
listOf("You'll get the one-line notice when it lands.", "Dev Updater fix is pushed."),
texts(items),
)
}
@Test
fun deltas_of_one_reply_still_accumulate_into_it() {
val items =
fold(
SessionEvent.AssistantText("Still "),
SessionEvent.AssistantText("running "),
SessionEvent.Status("running"),
SessionEvent.AssistantText("its tests."),
)
assertEquals(listOf("Still running its tests."), texts(items))
}
@Test
fun completed_text_replaces_provisional_deltas_live() {
val items =
fold(
SessionEvent.AssistantText("I'll inspect the color-c concrete implementation"),
SessionEvent.AssistantTextFinal(
"Ill inspect the color-correction TODO and the relevant design."
),
)
assertEquals(
listOf("Ill inspect the color-correction TODO and the relevant design."),
texts(items),
)
}
@Test
fun completed_text_discards_provisional_deltas_across_a_page_boundary() {
val earlier = fold(SessionEvent.AssistantText("1. provisional section\n\n"))
val later =
fold(
SessionEvent.AssistantTextFinal("1. final first section\n\n2. final second section")
)
assertEquals(
listOf("1. final first section\n\n2. final second section"),
texts(joinPages(earlier, later)),
)
}
@Test
fun a_final_value_after_a_settled_reply_is_a_new_message_across_a_page_boundary() {
val earlier =
fold(
SessionEvent.AssistantText("Previous answer."),
SessionEvent.Status("idle"),
)
val later = fold(SessionEvent.AssistantTextFinal("Next answer."))
assertEquals(
listOf("Previous answer.", "Next answer."),
texts(joinPages(earlier, later)),
)
}
/**
* The rule that replaced the wall of reports. A turn that starts with nothing recorded in front
* of it -- a subagent finishing, the CLI picking a conversation back up -- leaves two replies
* abutting, and only the break says they are two.
*/
@Test
fun two_replies_that_meet_are_separated_by_a_rule_and_nothing_else() {
val items =
fold(
SessionEvent.AssistantText("Launched it."),
SessionEvent.Status("waiting"),
SessionEvent.AssistantText("Noted."),
)
assertEquals(3, items.size, "$items")
assertTrue(items[1] is TranscriptItem.TurnBreak, "$items")
assertEquals(listOf("Launched it.", "Noted."), texts(items))
// Distinct keys: the break shares the reply's seq, and two items with one key take the
// app down.
assertEquals(3, items.map { it.key }.toSet().size, "$items")
}
/**
* A reply after anything that draws a row of its own needs no rule: that row is the boundary.
*/
@Test
fun a_reply_after_a_row_of_its_own_gets_no_rule() {
val items =
fold(
SessionEvent.AssistantText("Launched it."),
SessionEvent.Status("idle"),
SessionEvent.UserMessage("carry on", null, emptyList()),
SessionEvent.AssistantText("Noted."),
)
assertTrue(items.none { it is TranscriptItem.TurnBreak }, "$items")
}
@Test
fun a_repeated_tool_start_is_still_one_row() {
val start = SessionEvent.ToolStart("exec-1", "Bash", "{\"command\":\"cargo test\"}")
val items =
fold(
start,
SessionEvent.AssistantText("The test run is still going."),
start,
SessionEvent.ToolEnd("exec-1", "finished"),
)
val tools = items.filterIsInstance<TranscriptItem.ToolRun>()
assertEquals(1, tools.size, "$items")
assertEquals("finished", tools.single().output)
assertTrue(tools.single().done)
}
/**
* The page-join half of the same rule. A boundary that cuts one reply leaves an unfinished half
* to be rejoined; a boundary that lands between two turns must not join anything, or paging
* back puts the run-together paragraph straight back.
*/
@Test
fun paging_back_rejoins_a_cut_reply_and_leaves_two_finished_ones_apart() {
val cut =
joinPages(
listOf(TranscriptItem.AssistantMsg(1, "half a ")),
listOf(TranscriptItem.AssistantMsg(2, "sentence", settled = true)),
)
assertEquals(listOf("half a sentence"), texts(cut))
val whole =
joinPages(
listOf(TranscriptItem.AssistantMsg(1, "One turn.", settled = true)),
listOf(TranscriptItem.AssistantMsg(2, "The next.", settled = true)),
)
assertEquals(listOf("One turn.", "The next."), texts(whole))
// And the rule between them, which the fold that would have made it never got to see.
assertTrue(whole.any { it is TranscriptItem.TurnBreak }, "$whole")
}
}
+1
View File
@@ -45,6 +45,7 @@ GLYPHS=(
U+F0193 # md-content_save U+F0193 # md-content_save
U+F0224 # md-file_outline U+F0224 # md-file_outline
U+F201 # fa-line_chart -- Font Awesome's, asked for by name U+F201 # fa-line_chart -- Font Awesome's, asked for by name
U+F035C # md-menu -- the burger, as a row's drag handle
) )
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
+2 -2
View File
@@ -120,10 +120,10 @@ TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; } api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
echo "==> Importing" echo "==> Importing"
SETUP=$(api "https://127.0.0.1:$PORT/setups" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) MACHINE=$(api "https://127.0.0.1:$PORT/machines" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
SESSION=$(api -H 'Content-Type: application/json' -X POST \ SESSION=$(api -H 'Content-Type: application/json' -X POST \
"https://127.0.0.1:$PORT/sessions" \ "https://127.0.0.1:$PORT/sessions" \
-d "{\"setup\":\"$SETUP\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \ -d "{\"machine\":\"$MACHINE\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events" echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events"
+1 -1
View File
@@ -9,7 +9,7 @@
# emulator" is three places for the memory check that was missing from all of # emulator" is three places for the memory check that was missing from all of
# them. # them.
# #
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh, # Environment machine (SDK location, PATH, ...) lives in ./android-env.sh,
# which can also be sourced directly for one-off commands. # which can also be sourced directly for one-off commands.
set -eu set -eu
+17 -6
View File
@@ -110,7 +110,7 @@ api) # ./ui-sandbox.sh api /path [curl args...]
;; ;;
spawn) # ./ui-sandbox.sh spawn [title] -- an echo session; prints its id spawn) # ./ui-sandbox.sh spawn [title] -- an echo session; prints its id
api /sessions -X POST -H 'content-type: application/json' \ api /sessions -X POST -H 'content-type: application/json' \
-d "{\"setup\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" | -d "{\"machine\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'
exit 0 exit 0
;; ;;
@@ -150,7 +150,7 @@ if [ -f "$ROOT/config.ron" ]; then
/^tokens: \[/ { in_tokens = 1; next } /^tokens: \[/ { in_tokens = 1; next }
# The server writes the list back compactly, with the last entry # The server writes the list back compactly, with the last entry
# and the close on one line: " ),],". Reading the close only # and the close on one line: " ),],". Reading the close only
# at a line start ran past it into `setups`, and the salvage then # at a line start ran past it into `machines`, and the salvage then
# carried a second copy of that block into the new config. # carried a second copy of that block into the new config.
in_tokens && /\],/ { in_tokens && /\],/ {
sub(/\],.*/, "") sub(/\],.*/, "")
@@ -213,13 +213,24 @@ while [ "$i" -le 8 ]; do
i=$((i + 1)) i=$((i + 1))
done done
# A CLI that does nothing, so importing one of these is free and safe. # A CLI that does nothing during a session and offers one deterministic login
# Everything the spawn path cares about is here: it holds the fifo open, # during `auth login`, so both paths are free and safe. Everything the spawn
# records a real pid, writes nothing, and dies on a signal. A real # path cares about is here: it holds the fifo open, records a real pid, writes
# nothing, and dies on a signal. A real
# `claude --resume` against an invented session id would either fail in a # `claude --resume` against an invented session id would either fail in a
# way that tests nothing or start a turn on somebody's account. # way that tests nothing or start a turn on somebody's account.
cat >"$ROOT/fake-claude" <<FAKE cat >"$ROOT/fake-claude" <<FAKE
#!/bin/sh #!/bin/sh
if [ "\${1:-}" = auth ] && [ "\${2:-}" = login ]; then
echo 'https://claude.com/cai/oauth/authorize?state=ai-app-sandbox'
while IFS= read -r code; do
if [ "\$code" = sandbox-code ]; then
exit 0
fi
echo 'Invalid code' >&2
done
exit 1
fi
# Slow to start, on purpose. An import against this finishes in # Slow to start, on purpose. An import against this finishes in
# milliseconds otherwise, so every state on the way -- the row marked # milliseconds otherwise, so every state on the way -- the row marked
# "importing", the queue behind it, the event that clears them -- is over # "importing", the queue behind it, the event that clears them -- is over
@@ -346,7 +357,7 @@ tokens: [
sha256: "$hash", sha256: "$hash",
), ),
$salvaged], $salvaged],
setups: [ machines: [
( (
id: "local", id: "local",
name: "sandbox", name: "sandbox",
+1
View File
@@ -35,6 +35,7 @@ dependencies = [
"sha2", "sha2",
"tempfile", "tempfile",
"thiserror", "thiserror",
"time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tower", "tower",
+6
View File
@@ -53,6 +53,12 @@ ureq = { version = "3", features = ["json"] }
# both in the graph rustls refuses to auto-select one. # both in the graph rustls refuses to auto-select one.
rustls = "0.23" rustls = "0.23"
libc = "0.2.189" libc = "0.2.189"
# One ISO-8601 timestamp: the reset time on the invented rate-limit window
# an echo session's `/usage` puts up. Already in the tree behind the
# certificate machinery, so this is a direct name for what is compiled
# anyway rather than a new crate -- and the alternative was hand-rolling a
# civil-from-days conversion to print one line.
time = { version = "0.3", features = ["formatting"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+14
View File
@@ -207,6 +207,20 @@ mod tests {
/// like any other. /// like any other.
#[tokio::test] #[tokio::test]
async fn a_spooled_enrollment_is_adopted_on_first_use() { async fn a_spooled_enrollment_is_adopted_on_first_use() {
// Under a subscriber, like every other exercise of this middleware.
// `tracing` caches a callsite's interest process-wide the first time it
// is reached, so the refusal at the end of this test -- reached with no
// subscriber on this thread -- could cache the rejection warning as
// never-enabled and make the tripwire above see an empty log. That
// failed about one full-suite run in ten, in the test that exists to
// notice a credential leak, which is the worst place for a flake.
let _guard = tracing::subscriber::set_default(
tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_writer(std::io::sink)
.finish(),
);
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let manager = manager_with_token(dir.path(), "first"); let manager = manager_with_token(dir.path(), "first");
let spooled = generate_token(); let spooled = generate_token();
+541 -46
View File
@@ -28,8 +28,28 @@ pub struct Config {
/// credential. A list (of one, today) so per-device tokens with individual /// credential. A list (of one, today) so per-device tokens with individual
/// revocation are a config entry later, not a migration. /// revocation are a config entry later, not a migration.
pub tokens: Vec<TokenEntry>, pub tokens: Vec<TokenEntry>,
pub setups: Vec<SetupConfig>, /// `setups` is the persisted spelling before the machine/provider boundary
/// was named correctly. Read it once so an update does not discard the
/// machines already configured; every subsequent write uses `machines`.
#[serde(alias = "setups")]
pub machines: Vec<MachineConfig>,
/// Every session, in the order the reader has put them in -- what `GET
/// /sessions` answers in and what every screen draws. A drag on the phone
/// rewrites this (`POST /sessions/order`), and a spawn appends, so a new
/// session arrives at the bottom rather than displacing anything.
pub sessions: Vec<SessionConfig>, pub sessions: Vec<SessionConfig>,
/// What a new session's thinking level is when nothing chose one.
///
/// Here rather than on a provider because providers are *discovered*: a
/// default written onto one would be erased by the next rediscovery, which
/// is the kind of setting that looks like it stuck until the day it did
/// not. Here rather than on the phone because a second device would then
/// spawn sessions the first one's owner did not expect.
///
/// `None` is the CLI's own default, and stays reachable: this is a level
/// somebody chose, not a level this app picked for them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_effort: Option<String>,
} }
/// A machine, and the things it can run. /// A machine, and the things it can run.
@@ -40,8 +60,8 @@ pub struct Config {
/// host and offered the whole cross-product. /// host and offered the whole cross-product.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SetupConfig { pub struct MachineConfig {
/// Stable identifier, minted when the setup is added and never /// Stable identifier, minted when the machine is added and never
/// changed. Sessions reference this rather than the label, so /// changed. Sessions reference this rather than the label, so
/// renaming a machine on the phone does not orphan its sessions -- /// renaming a machine on the phone does not orphan its sessions --
/// which is the whole reason the two are separate fields. /// which is the whole reason the two are separate fields.
@@ -50,19 +70,19 @@ pub struct SetupConfig {
/// How to reach it, absent for this machine. /// How to reach it, absent for this machine.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<SshConfig>, pub ssh: Option<SshConfig>,
/// What can be spawned here. Names are unique within a setup, and only /// What can be spawned here. Names are unique within a machine, and only
/// within it: two machines may each have a `claude-cli`, which is the point. /// within it: two machines may each have a `claude-cli`, which is the point.
#[serde(default)] #[serde(default)]
pub providers: Vec<ProviderConfig>, pub providers: Vec<ProviderConfig>,
} }
impl SetupConfig { impl MachineConfig {
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> { pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
self.providers.iter().find(|provider| provider.name == name) self.providers.iter().find(|provider| provider.name == name)
} }
} }
/// One thing a setup can run: which driver, and how to invoke it. /// One thing a machine can run: which driver, and how to invoke it.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProviderConfig { pub struct ProviderConfig {
@@ -76,9 +96,59 @@ pub struct ProviderConfig {
/// too; this is a shortcut list, not a restriction. /// too; this is a shortcut list, not a restriction.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>, pub models: Vec<String>,
/// MCP servers whose tools this provider's sessions can use, on top of
/// whatever the provider runs itself.
///
/// On the provider rather than the machine, because it is a statement
/// about what a session can do rather than about where it runs -- and
/// because only a driver that runs its own agent loop can use one. Today
/// that is llama.cpp; the coding CLIs have their own MCP configuration
/// and this would be a second, quieter answer to the same question.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_servers: Vec<McpServerConfig>,
/// How each model this provider serves is loaded, by model key -- the
/// settings in [`LLAMA_MODEL_PARAMS`].
///
/// On the model rather than on the session because one loaded model is
/// what several sessions talk to: a machine's `llama-server` holds it
/// once, and a context size or a layer count that two sessions disagreed
/// about would be one of them being ignored. See `session::llama::router`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub model_settings: BTreeMap<String, BTreeMap<String, String>>,
/// How many models this provider's server keeps loaded at once before it
/// evicts the least recently used. `None` is one, which is the right
/// answer for a machine with one GPU -- see `router`'s `DEFAULT_MAX_LOADED`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_loaded: Option<u32>,
} }
/// How to reach a setup that isn't this machine, with the system `ssh` client /// An MCP server reached over HTTP.
///
/// A URL and nothing else: this backend connects to remote servers rather than
/// spawning local ones, so there is no command, no arguments and no
/// environment to configure. See `session::llama::mcp` for why that is the
/// shape -- in short, it is what llama.cpp's own web UI does, and it keeps the
/// tools on the machine with a route out rather than the machine with the GPU.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServerConfig {
/// Prefixes every tool this server offers, so two servers with a `search`
/// are two tools. Also what a failure to connect is named by.
pub name: String,
pub url: String,
}
impl ProviderConfig {
/// The executable to run for this provider: its override, or its kind's
/// default.
pub fn program(&self) -> &str {
self.command
.as_deref()
.unwrap_or(self.kind.default_program())
}
}
/// How to reach a machine that isn't this machine, with the system `ssh` client
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is /// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
/// one place to configure connections. A remote session is the identical /// one place to configure connections. A remote session is the identical
/// command with `ssh host …` in front, and nothing downstream knows. /// command with `ssh host …` in front, and nothing downstream knows.
@@ -94,6 +164,19 @@ pub struct SshConfig {
/// Extra `-o` settings, each written as `Key=value`. /// Extra `-o` settings, each written as `Key=value`.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<String>, pub options: Vec<String>,
/// Where this machine keeps the GGUF models it can serve, absent for
/// the same default this backend uses (`~/.local/share/ai-app/models`
/// -- `$XDG_DATA_HOME` is not read on the far side, since it is this
/// machine's environment that would answer). A `~` prefix is the
/// remote home.
///
/// Here rather than on the provider because it is a fact about the
/// machine, and because a machine reached over ssh is where the model
/// has to be: a llama.cpp session serves the file from the machine
/// that runs `llama-server`, and this backend's own downloads are on
/// whichever machine that is only when they are the same one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub models_dir: Option<PathBuf>,
/// Where a file attached from the phone is put on this machine so the /// Where a file attached from the phone is put on this machine so the
/// session can read it. Absent means the session's own working directory, /// session can read it. Absent means the session's own working directory,
/// or the login home for a session that has none. A `~` prefix is the /// or the login home for a session that has none. A `~` prefix is the
@@ -121,6 +204,8 @@ pub enum DriverKind {
/// The Claude Code CLI over stream-json. Named for the CLI specifically: /// The Claude Code CLI over stream-json. Named for the CLI specifically:
/// bare "claude" would suggest the credit-billed API, which this is not. /// bare "claude" would suggest the credit-billed API, which this is not.
ClaudeCli, ClaudeCli,
/// The Codex CLI's persistent app-server JSONL protocol.
CodexCli,
} }
impl DriverKind { impl DriverKind {
@@ -141,28 +226,330 @@ impl DriverKind {
pub fn max_image_edge(self) -> Option<u32> { pub fn max_image_edge(self) -> Option<u32> {
match self { match self {
DriverKind::ClaudeCli => Some(1568), DriverKind::ClaudeCli => Some(1568),
DriverKind::Echo | DriverKind::LlamaCpp => None, DriverKind::Echo | DriverKind::LlamaCpp | DriverKind::CodexCli => None,
}
}
/// Which paid service meters a session of this kind, and `None` for one
/// that costs nothing.
///
/// What decides which account -- if any -- a rate-limit bar is about is the
/// provider a session runs, not the machine it runs on: an echo session on
/// a machine that also has the Claude CLI was drawn with that CLI's
/// five-hour window, a quota it cannot spend.
///
/// Echo names a meter of its own that exists only when a test has asked for
/// one (`/usage` in `session::echo`), which is how the bar's states are
/// reached without an account. With none set there is no snapshot, and the
/// phone draws nothing.
///
/// The string is a [`crate::usage::UsageProvider::name`], and it is what
/// pairs a session with one of `GET /usage`'s snapshots -- so
/// `usage::providers_for` reads this rather than matching on kinds again.
pub fn usage_provider(self) -> Option<&'static str> {
match self {
Self::ClaudeCli => Some(crate::usage::CLAUDE),
Self::CodexCli => Some(crate::usage::CODEX),
Self::Echo => Some(crate::usage::ECHO),
Self::LlamaCpp => None,
}
}
/// The executable a provider of this kind runs when it names none.
///
/// Here rather than at each spawn site because it is not only the spawn
/// that runs it: `usage` runs provider CLIs too, so a default that
/// disagreed with the driver's would ask the wrong binary on a machine
/// with two installs.
pub fn default_program(self) -> &'static str {
match self {
Self::ClaudeCli => "claude",
Self::CodexCli => "codex",
Self::LlamaCpp => "llama-server",
// Echo is translated in-process; nothing is spawned for it.
Self::Echo => "echo",
} }
} }
/// Whether the conversation exists outside this app, so that deleting the /// Whether the conversation exists outside this app, so that deleting the
/// session here does not end it. /// session here does not end it.
/// ///
/// The Claude Code CLI owns its own transcript and is resumable from it /// Claude Code and Codex own their transcripts and can resume them
/// whatever started it, so a session this app spawned is every bit as /// independently of this app. Echo has nothing to keep, and a llama
/// recoverable as one it imported. Echo has nothing to keep, and a llama
/// session's conversation is folded out of *this* app's transcript. /// session's conversation is folded out of *this* app's transcript.
/// ///
/// Asked before warning somebody that a deletion cannot be undone, which is /// Asked before warning somebody that a deletion cannot be undone, which is
/// the one sentence that has to be true: said of a session that can in fact /// the one sentence that has to be true: said of a session that can in fact
/// be brought back, it spends the credibility the warning needs. /// be brought back, it spends the credibility the warning needs.
pub fn keeps_own_transcript(self) -> bool { pub fn keeps_own_transcript(self) -> bool {
self.own_transcript_name().is_some()
}
/// The product whose durable transcript survives an ordinary app delete.
/// Reported to the phone because a provider's configured name is not the
/// name of its storage, and calling Codex's rollout a Claude transcript is
/// especially misleading on an irreversible switch.
pub fn own_transcript_name(self) -> Option<&'static str> {
match self { match self {
Self::ClaudeCli => true, Self::ClaudeCli => Some("Claude Code"),
Self::CodexCli => Some("Codex"),
Self::Echo | Self::LlamaCpp => None,
}
}
/// Whether a thinking level means anything to this kind, so the phone can
/// offer the control only where it does something.
///
/// Reported from here rather than decided on the phone, and asked of the
/// *kind* rather than branched on: the alternative is the session-type
/// `if` this app does not have anywhere else. Coding CLIs take an effort
/// setting; a llama session's sampling is `params`, and echo does not think.
///
/// It matters more than a control that would simply do nothing, because
/// choosing a level stops the process -- so on a session that cannot use
/// one it is a button whose only effect is the cost.
pub fn takes_effort(self) -> bool {
match self {
Self::ClaudeCli | Self::CodexCli => true,
Self::Echo | Self::LlamaCpp => false, Self::Echo | Self::LlamaCpp => false,
} }
} }
/// Permission choices the phone can offer for this kind, in display order.
/// The driver remains responsible for translating these stable values into
/// its CLI's arguments or control protocol.
pub fn permission_modes(self) -> &'static [&'static str] {
match self {
Self::ClaudeCli => &["manual", "acceptEdits", "auto", "bypassPermissions", "plan"],
Self::CodexCli => &["workspace-write", "read-only", "danger-full-access"],
// Named by the driver that enforces them rather than repeated
// here: this list and the one the gate matches on being two
// literals is how a mode comes to be offered and then refused.
Self::LlamaCpp => crate::session::llama::PERMISSION_MODES,
Self::Echo => &[],
} }
}
/// The settings this kind of session takes beyond the shared ones, for
/// the phone to offer.
///
/// Declared rather than drawn: a spawn screen with a field per llama
/// setting is a screen that has to be edited every time a driver grows
/// one, and this app already has the `params` map to carry them. So the
/// server says what a provider takes and the phone renders it, which is
/// the same arrangement `permission_modes` uses and for the same reason
/// -- the alternative is two lists that disagree, one of them in Kotlin.
///
/// It is also what keeps these *reachable at all*. Several were hardcoded
/// to the values measured on one machine, which is fine as a default and
/// wrong as a constant: the next machine has a different GPU and a
/// different number of cores, and nobody running this app can edit the
/// source.
pub fn params(self) -> &'static [ParamSpec] {
match self {
Self::LlamaCpp => LLAMA_PARAMS,
Self::Echo | Self::ClaudeCli | Self::CodexCli => &[],
}
}
/// What a *model* this kind serves takes, which is nothing for a kind
/// that does not load models of its own. Separate from [`params`] because
/// the two have different owners, not different shapes: a session's ride
/// on its requests, a model's decide how the machine loads it.
pub fn model_params(self) -> &'static [ParamSpec] {
match self {
Self::LlamaCpp => LLAMA_MODEL_PARAMS,
Self::Echo | Self::ClaudeCli | Self::CodexCli => &[],
}
}
/// The mode used when a new-session form first selects this kind.
pub fn default_permission_mode(self) -> Option<&'static str> {
match self {
Self::ClaudeCli => Some("auto"),
Self::CodexCli => Some("workspace-write"),
Self::LlamaCpp => Some(crate::session::llama::DEFAULT_PERMISSION_MODE),
Self::Echo => None,
}
}
}
/// One setting a provider takes, and enough about it to draw a control.
///
/// Deliberately thin: a key, words for a person, and which shape the value
/// has. Anything richer -- units, validation, dependencies between settings --
/// would be a schema language, and what the phone needs is a text field or a
/// row of chips.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ParamSpec {
/// The `SessionConfig::params` key this writes.
pub key: &'static str,
pub label: &'static str,
/// What happens when it is not set, in words. Shown where a control shows
/// its placeholder, so "blank" always means something specific rather than
/// leaving the reader to guess whether it means zero.
pub unset: &'static str,
/// Flattened, so a spec is one flat object: `kind` beside the rest rather
/// than an object of its own with `kind` inside it.
#[serde(flatten)]
pub kind: ParamKind,
/// Whether changing it waits for the process to start again.
///
/// The honest half of offering these live. A sampling setting rides on the
/// next request; a server flag was decided when the model was loaded, and
/// a control that silently did nothing until some later restart would be
/// worse than one that is not there.
pub restart: bool,
}
/// What shape a [`ParamSpec`]'s value has.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum ParamKind {
Integer,
Decimal,
Text,
/// A fixed set. **The first option is what leaving it unset means**, and
/// choosing it clears the setting rather than storing a value -- so the
/// default is a state the picker can return to, and the stored config
/// does not fill up with values nobody chose.
Choice {
options: &'static [&'static str],
},
}
/// What a llama.cpp **session** takes: everything that rides on a request.
///
/// Nothing here waits for a restart, and that is a property of the split
/// rather than a coincidence. A machine's `llama-server` holds one copy of a
/// model for every session using it, so how that model is *loaded* cannot be
/// one session's to decide -- those settings are [`LLAMA_MODEL_PARAMS`],
/// against the model on its machine.
const LLAMA_PARAMS: &[ParamSpec] = &[
ParamSpec {
key: "tools",
label: "Tools",
// Worth a control rather than a constant because of what it costs:
// a prompt measured 2,181 tokens with all seven and 698 with none,
// every turn, before anything is said. A filter this backend applies
// to what the machine's server offers, so unlike the flag it replaced
// it takes effect on the next message.
unset: "all of them -- or a comma-separated list, or \"none\"",
kind: ParamKind::Text,
restart: false,
},
ParamSpec {
key: "thinking",
label: "Thinking",
// Rides on the request, like the sampling settings below it: the level
// is a chat-template argument rather than a server flag, so a session
// changes how hard it thinks without reloading its model.
unset: "however hard the model thinks by default",
// Every level any of these templates uses, because which of them a
// *particular* model takes is the model's business and only the loaded
// one can answer it -- the driver asks it (`thinking_options`) and says
// what it takes when a level it cannot is chosen. "off" is the one that
// is not a level: it asks the template for no thinking at all.
kind: ParamKind::Choice {
options: &["auto", "off", "low", "medium", "high", "xhigh", "max"],
},
restart: false,
},
ParamSpec {
key: "temperature",
label: "Temperature",
unset: "llama.cpp's default",
kind: ParamKind::Decimal,
restart: false,
},
ParamSpec {
key: "topP",
label: "Top P",
unset: "llama.cpp's default",
kind: ParamKind::Decimal,
restart: false,
},
ParamSpec {
key: "topK",
label: "Top K",
unset: "llama.cpp's default",
kind: ParamKind::Integer,
restart: false,
},
ParamSpec {
key: "maxTokens",
label: "Reply limit",
unset: "no limit",
kind: ParamKind::Integer,
restart: false,
},
];
/// What a llama.cpp **model** takes: everything that decides how it is loaded.
///
/// Per model on its machine rather than per session, because one loaded copy
/// is what every session on that model is talking to. Changing one reloads
/// that model -- for everybody using it, which is the honest consequence of
/// sharing it and is what the machine's provider view says before saving.
///
/// Every key here is written into the router's preset file as
/// `llama-server`'s own argument name, so adding a setting is a row here and a
/// row in `session::llama::router`'s `section`.
pub const LLAMA_MODEL_PARAMS: &[ParamSpec] = &[
ParamSpec {
key: "contextSize",
label: "Context size",
unset: "the model's own trained context",
kind: ParamKind::Integer,
// Every one of these does, which is what makes them the model's: see
// the doc comment above.
restart: true,
},
ParamSpec {
key: "gpuLayers",
label: "Layers on the GPU",
unset: "as many as fit",
kind: ParamKind::Integer,
restart: true,
},
ParamSpec {
key: "threads",
label: "Threads",
unset: "one per core",
kind: ParamKind::Integer,
restart: true,
},
ParamSpec {
key: "slots",
label: "Sessions answered at once",
// One, so that a second session's turn waits rather than splitting the
// model's cache. Measured 2026-09-19 on the 27B here: 41.5 tok/s
// plain, 61.4 with the draft head at one slot, and 28 with the head at
// four -- speculating against a split cache is slower than not
// speculating at all. Worth raising on a machine where several
// sessions really are used together and drafting does not pay.
unset: "one -- a second session's turn waits for the first",
kind: ParamKind::Integer,
restart: true,
},
ParamSpec {
key: "speculative",
label: "Speculative decoding",
unset: "on, for a model whose file carries a draft head",
kind: ParamKind::Choice {
options: &["auto", "off"],
},
restart: true,
},
ParamSpec {
key: "specDraftNMax",
label: "Tokens drafted ahead",
unset: "llama.cpp's own default",
kind: ParamKind::Integer,
restart: true,
},
];
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -178,12 +565,15 @@ pub struct TokenEntry {
pub struct SessionConfig { pub struct SessionConfig {
/// Stable identifier; names the session's directory and its routes. /// Stable identifier; names the session's directory and its routes.
pub id: String, pub id: String,
/// Id of the [`SetupConfig`] this session runs on -- the id, not the label, /// Id of the [`MachineConfig`] this session runs on -- the id, not the label,
/// so the machine can be renamed without losing its sessions. /// so the machine can be renamed without losing its sessions.
pub setup: String, /// `setup` is accepted only as the on-disk migration from builds that used
/// Name of the provider within that setup. Both stored by name rather than /// that word for a machine. The API and newly written records say `machine`.
/// resolved, so an edited setup takes effect on the next relaunch; a session #[serde(alias = "setup")]
/// whose setup or provider is gone reports as exited and can still be pub machine: String,
/// Name of the provider within that machine. Both stored by name rather than
/// resolved, so an edited machine takes effect on the next relaunch; a session
/// whose machine or provider is gone reports as exited and can still be
/// deleted. /// deleted.
pub provider: String, pub provider: String,
pub title: String, pub title: String,
@@ -191,11 +581,19 @@ pub struct SessionConfig {
pub model: Option<String>, pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>, pub cwd: Option<PathBuf>,
/// Claude permission mode chosen at spawn. Kept as a string because it is /// Provider permission mode chosen at spawn. Kept as a string because the
/// passed straight to `--permission-mode` rather than interpreted here, so /// driver maps it onto its CLI rather than shared code interpreting it.
/// the CLI stays the one authority on which modes exist.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>, pub permission_mode: Option<String>,
/// How hard the model thinks. A string for the same reason
/// `permission_mode` is: the CLI owns which levels exist.
///
/// This is settled at launch and `None` means whatever the CLI's own
/// default is. That is a state the phone has to be able to *choose*, not
/// just start in, which is why it is an option rather than a level with a
/// default written here.
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
/// Settings the driver interprets, chosen at spawn. /// Settings the driver interprets, chosen at spawn.
/// ///
/// Deliberately untyped: what a temperature or a context size means is the /// Deliberately untyped: what a temperature or a context size means is the
@@ -215,6 +613,30 @@ pub struct SessionConfig {
/// turned off in one tap where one that never arrived is not diagnosable. /// turned off in one tap where one that never arrived is not diagnosable.
#[serde(default = "notify_default")] #[serde(default = "notify_default")]
pub notify: bool, pub notify: bool,
/// Whether a session stopped by the account's usage limit sends itself a
/// message once the limit lifts, instead of waiting for a person.
///
/// Off unless somebody asked for it. It spends quota the moment it becomes
/// available and it does so while nobody is looking, which is exactly the
/// kind of thing that must not happen because a default said so.
#[serde(default, skip_serializing_if = "not_set")]
pub auto_resume: bool,
/// What that message says. `None` is [`DEFAULT_RESUME_MESSAGE`], and stays
/// reachable: it is this app's word, not one somebody chose, so clearing
/// the field goes back to it rather than sending an empty message.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_resume_message: Option<String>,
/// The message this session owes itself once the limit lifts, and when to
/// try. Written when a limit is hit, moved when the wait turns out to be
/// wrong, and cleared when the message goes out or auto-resume is turned
/// off -- see [`ScheduledResume`].
///
/// Persisted rather than held in memory because the wait outlives the
/// process doing it: a five-hour window and a weekly one both routinely
/// outlast a backend restart, and a resume forgotten across one is a
/// session that silently never comes back.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume: Option<ScheduledResume>,
/// Whether this session's process is stopped when the server exits, instead /// Whether this session's process is stopped when the server exits, instead
/// of being left running for the next start to adopt. /// of being left running for the next start to adopt.
/// ///
@@ -231,6 +653,28 @@ pub struct SessionConfig {
pub created: f64, pub created: f64,
} }
/// A message owed to a session whose account ran out, and when to try sending
/// it.
///
/// `since` is the whole reason this is a struct: the wait is rescheduled every
/// time the meter is asked and still says no, so `at` alone cannot say how long
/// this has been going on -- and something has to, or a machine that can never
/// be asked is retried until somebody notices. See `crate::resume`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledResume {
/// Epoch seconds: when the limit is next worth checking. Never a promise
/// that the message goes out then -- the meter is asked first.
pub at: f64,
/// Epoch seconds the limit was hit.
pub since: f64,
}
/// What an auto-resume says when nothing else was chosen. One word, because
/// the session already knows what it was doing and this is only the nudge that
/// lets it carry on.
pub const DEFAULT_RESUME_MESSAGE: &str = "continue";
fn notify_default() -> bool { fn notify_default() -> bool {
true true
} }
@@ -241,17 +685,17 @@ fn not_set(flag: &bool) -> bool {
!*flag !*flag
} }
/// The name of the echo provider, and of the setup this machine gets on first /// The name of the echo provider, and of the local machine created on first
/// run. /// run.
/// ///
/// Echo is seeded into the config rather than conjured at read time. An /// Echo is seeded into the config rather than conjured at read time. An
/// implicit provider is one a person cannot see in the file or edit from the /// implicit provider is one a person cannot see in the file or edit from the
/// phone; if somebody deletes it, that was a choice. /// phone; if somebody deletes it, that was a choice.
pub const ECHO_PROVIDER: &str = "echo"; pub const ECHO_PROVIDER: &str = "echo";
pub const LOCAL_SETUP: &str = "this machine"; pub const LOCAL_MACHINE: &str = "this machine";
/// The id of the setup a fresh install seeds. Fixed rather than random so a /// The id of the machine a fresh install seeds. Fixed rather than random so a
/// hand-written config can name it without looking one up. /// hand-written config can name it without looking one up.
pub const LOCAL_SETUP_ID: &str = "local"; pub const LOCAL_MACHINE_ID: &str = "local";
/// Where `ai-server --enroll-link` leaves a token for the running server to /// Where `ai-server --enroll-link` leaves a token for the running server to
/// adopt: beside the config, since it is config in transit. /// adopt: beside the config, since it is config in transit.
@@ -260,15 +704,15 @@ pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
} }
impl Config { impl Config {
pub fn setup(&self, id: &str) -> Option<&SetupConfig> { pub fn machine(&self, id: &str) -> Option<&MachineConfig> {
self.setups.iter().find(|setup| setup.id == id) self.machines.iter().find(|machine| machine.id == id)
} }
/// A setup by the label a person sees, for messages and for the one place a /// A machine by the label a person sees, for messages and for the one place a
/// name still arrives from outside. Nothing else should look one up this /// name still arrives from outside. Nothing else should look one up this
/// way, since labels are editable and ids are not. /// way, since labels are editable and ids are not.
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> { pub fn machine_named(&self, name: &str) -> Option<&MachineConfig> {
self.setups.iter().find(|setup| setup.name == name) self.machines.iter().find(|machine| machine.name == name)
} }
/// This machine, offering whatever was found on it. /// This machine, offering whatever was found on it.
@@ -277,10 +721,10 @@ impl Config {
/// be *discovered*: a hardcoded list is a claim about what is installed, and /// be *discovered*: a hardcoded list is a claim about what is installed, and
/// this one was wrong -- every fresh install asserted a `claude-cli` /// this one was wrong -- every fresh install asserted a `claude-cli`
/// provider whether or not `claude` existed. /// provider whether or not `claude` existed.
pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig { pub fn seed(providers: Vec<ProviderConfig>) -> MachineConfig {
SetupConfig { MachineConfig {
id: LOCAL_SETUP_ID.to_string(), id: LOCAL_MACHINE_ID.to_string(),
name: LOCAL_SETUP.to_string(), name: LOCAL_MACHINE.to_string(),
ssh: None, ssh: None,
providers, providers,
} }
@@ -295,6 +739,9 @@ impl Config {
kind: DriverKind::Echo, kind: DriverKind::Echo,
command: None, command: None,
models: Vec::new(), models: Vec::new(),
mcp_servers: Vec::new(),
model_settings: BTreeMap::new(),
max_loaded: None,
} }
} }
@@ -355,11 +802,11 @@ mod tests {
let path = dir.path().join("config.ron"); let path = dir.path().join("config.ron");
// A missing file is the ordinary first-run state, not an error. // A missing file is the ordinary first-run state, not an error.
// Nothing is conjured to fill it: the seed setup is written by the // Nothing is conjured to fill it: the seed machine is written by the
// manager, so the file always says what there is. // manager, so the file always says what there is.
let first_run = Config::load(&path).expect("load"); let first_run = Config::load(&path).expect("load");
assert!(first_run.tokens.is_empty()); assert!(first_run.tokens.is_empty());
assert!(first_run.setups.is_empty()); assert!(first_run.machines.is_empty());
assert!(first_run.sessions.is_empty()); assert!(first_run.sessions.is_empty());
let config = Config { let config = Config {
@@ -367,7 +814,7 @@ mod tests {
name: "phone".to_string(), name: "phone".to_string(),
sha256: "ab".repeat(32), sha256: "ab".repeat(32),
}], }],
setups: vec![ machines: vec![
Config::seed(vec![ Config::seed(vec![
Config::echo_provider(), Config::echo_provider(),
ProviderConfig { ProviderConfig {
@@ -375,9 +822,12 @@ mod tests {
kind: DriverKind::ClaudeCli, kind: DriverKind::ClaudeCli,
command: Some("/usr/bin/claude".to_string()), command: Some("/usr/bin/claude".to_string()),
models: Vec::new(), models: Vec::new(),
mcp_servers: Vec::new(),
model_settings: BTreeMap::new(),
max_loaded: None,
}, },
]), ]),
SetupConfig { MachineConfig {
id: "vm".to_string(), id: "vm".to_string(),
name: "the vm".to_string(), name: "the vm".to_string(),
ssh: Some(SshConfig { ssh: Some(SshConfig {
@@ -385,6 +835,7 @@ mod tests {
port: Some(2222), port: Some(2222),
identity_file: None, identity_file: None,
options: Vec::new(), options: Vec::new(),
models_dir: None,
attachments_dir: None, attachments_dir: None,
}), }),
providers: vec![ProviderConfig { providers: vec![ProviderConfig {
@@ -392,19 +843,27 @@ mod tests {
kind: DriverKind::ClaudeCli, kind: DriverKind::ClaudeCli,
command: None, command: None,
models: vec!["haiku".to_string()], models: vec!["haiku".to_string()],
mcp_servers: Vec::new(),
model_settings: BTreeMap::new(),
max_loaded: None,
}], }],
}, },
], ],
default_effort: Some("low".to_string()),
sessions: vec![SessionConfig { sessions: vec![SessionConfig {
id: "abc123".to_string(), id: "abc123".to_string(),
setup: "vm".to_string(), machine: "vm".to_string(),
provider: "claude-cli".to_string(), provider: "claude-cli".to_string(),
title: "test".to_string(), title: "test".to_string(),
model: None, model: None,
cwd: None, cwd: None,
permission_mode: None, permission_mode: None,
effort: None,
params: BTreeMap::new(), params: BTreeMap::new(),
notify: true, notify: true,
auto_resume: false,
auto_resume_message: None,
resume: None,
throwaway: false, throwaway: false,
created: 1234.5, created: 1234.5,
}], }],
@@ -413,14 +872,14 @@ mod tests {
let loaded = Config::load(&path).expect("reload"); let loaded = Config::load(&path).expect("reload");
assert_eq!(loaded.tokens[0].name, "phone"); assert_eq!(loaded.tokens[0].name, "phone");
assert_eq!(loaded.sessions[0].setup, "vm"); assert_eq!(loaded.sessions[0].machine, "vm");
// The label and the id are separate, and the session holds the id. // The label and the id are separate, and the session holds the id.
assert_eq!(loaded.setup("vm").expect("setup").name, "the vm"); assert_eq!(loaded.machine("vm").expect("machine").name, "the vm");
assert_eq!(loaded.sessions[0].provider, "claude-cli"); assert_eq!(loaded.sessions[0].provider, "claude-cli");
assert_eq!( assert_eq!(
loaded loaded
.setup("vm") .machine("vm")
.expect("setup") .expect("machine")
.ssh .ssh
.as_ref() .as_ref()
.expect("ssh") .expect("ssh")
@@ -428,15 +887,21 @@ mod tests {
Some(2222), Some(2222),
); );
// The same provider name on two machines is the point, not a // The same provider name on two machines is the point, not a
// collision: names are unique within a setup and only within one. // collision: names are unique within a machine and only within one.
assert!( assert!(
loaded loaded
.setup(LOCAL_SETUP_ID) .machine(LOCAL_MACHINE_ID)
.expect("local") .expect("local")
.provider("claude-cli") .provider("claude-cli")
.is_some() .is_some()
); );
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none()); assert!(
loaded
.machine(LOCAL_MACHINE_ID)
.expect("local")
.ssh
.is_none()
);
// The house rule both halves of `format` depend on: what is written // The house rule both halves of `format` depend on: what is written
// is the *body* of the struct, with no outer parentheses and // is the *body* of the struct, with no outer parentheses and
@@ -458,6 +923,33 @@ mod tests {
); );
} }
#[test]
fn reads_setup_spelling_from_existing_configs_but_writes_machine_spelling() {
let old = r#"
setups: [(
id: "vm",
name: "the vm",
providers: [],
)],
sessions: [(
id: "abc123",
setup: "vm",
provider: "echo",
title: "old words",
created: 1234.5,
)],
"#;
let config: Config = format::parse(old).expect("old setup spelling still loads");
assert_eq!(config.machines[0].id, "vm");
assert_eq!(config.sessions[0].machine, "vm");
let written = format::render(&config).expect("render migrated config");
assert!(written.contains("machines:"), "{written}");
assert!(written.contains("machine: \"vm\""), "{written}");
assert!(!written.contains("setups:"), "{written}");
assert!(!written.contains("setup: \"vm\""), "{written}");
}
#[test] #[test]
/// The seed is this machine and nothing more: a name, no ssh, and /// The seed is this machine and nothing more: a name, no ssh, and
/// exactly the providers it was handed. /// exactly the providers it was handed.
@@ -469,7 +961,7 @@ mod tests {
/// this can check is that the seed does not invent anything. /// this can check is that the seed does not invent anything.
fn the_seed_is_this_machine_and_claims_only_what_it_was_given() { fn the_seed_is_this_machine_and_claims_only_what_it_was_given() {
let seed = Config::seed(vec![Config::echo_provider()]); let seed = Config::seed(vec![Config::echo_provider()]);
assert_eq!(seed.name, LOCAL_SETUP); assert_eq!(seed.name, LOCAL_MACHINE);
assert!(seed.ssh.is_none()); assert!(seed.ssh.is_none());
assert_eq!( assert_eq!(
seed.provider(ECHO_PROVIDER).expect("echo").kind, seed.provider(ECHO_PROVIDER).expect("echo").kind,
@@ -488,6 +980,9 @@ mod tests {
kind: DriverKind::ClaudeCli, kind: DriverKind::ClaudeCli,
command: Some("/usr/bin/claude".to_string()), command: Some("/usr/bin/claude".to_string()),
models: Vec::new(), models: Vec::new(),
mcp_servers: Vec::new(),
model_settings: BTreeMap::new(),
max_loaded: None,
}, },
]); ]);
assert_eq!( assert_eq!(
+2 -2
View File
@@ -1,7 +1,7 @@
//! Reading and changing files on the machine a setup names. //! Reading and changing files on a configured machine.
//! //!
//! Every operation here is one small POSIX shell script handed to `Transport`, //! Every operation here is one small POSIX shell script handed to `Transport`,
//! the way `setups::discover` and `import::list` already ask a machine a //! the way `machines::discover` and `import::list` already ask a machine a
//! question. That is what makes the local and the ssh case one implementation: //! question. That is what makes the local and the ssh case one implementation:
//! a second one written against `std::fs` would be the one that gets tested, //! a second one written against `std::fs` would be the one that gets tested,
//! and the remote half -- the ordering of entries, what a symlink reports, how //! and the remote half -- the ordering of entries, what a symlink reports, how
+359
View File
@@ -0,0 +1,359 @@
//! Just enough of the GGUF container to read a model's own name out of it.
//!
//! A `.gguf` file opens with a key/value table, and `general.name` in it is
//! what the people who published the model called it -- "Qwen3-0.6B",
//! "Qwen3.8-27B GSQ-RCO". Everything else this server knows a model by is
//! filesystem trivia: `owner/repo/file.gguf` is where it was downloaded from,
//! which is an address rather than a name, and on a phone it is a line of path
//! where a word would do.
//!
//! **Read as far as the answer and no further.** The same table holds the
//! tokenizer, which for a modern model is a 150,000-entry string array and
//! most of several megabytes; `general.*` is written first by every converter
//! in practice, so stopping at the name costs a few kilobytes instead. That is
//! what makes this affordable to run over every model in a directory, and what
//! lets the remote case work from a bounded prefix of the file rather than the
//! whole of it.
//!
//! Anything unreadable is [`None`] rather than an error, at every level. A
//! model with no name, a truncated prefix, a container version this does not
//! know and a file that is not GGUF at all are one answer here -- "this file
//! does not tell us" -- and the caller has a file name to fall back on. There
//! is nothing a reader could do with the distinction.
use std::io::Read;
/// How many bytes of a model file are worth fetching to look for its name.
///
/// Only the remote path needs a number: a local read stops when it finds the
/// key, but a file on another machine has to be asked for a fixed amount
/// before anything can be parsed. Measured 2026-09-19 against the two models
/// on this machine, `general.name` ends at byte **130** and **94** -- every
/// converter writes `general.*` before the tokenizer arrays that make up the
/// rest of the table. 8 KiB is two orders of magnitude of slack for that and
/// still makes listing a directory of models one round trip's worth of bytes
/// rather than a download, which is what decides the number: this is paid per
/// model every time a spawn screen opens.
pub const PREFIX_BYTES: u64 = 8 * 1024;
/// The longest string this will allocate for, so a corrupt length field
/// cannot ask for a gigabyte. Longer than any key or `general.*` value.
const MAX_STRING: u64 = 64 * 1024;
/// What `general.name` says, or `None` for every way of not finding out.
///
/// `read` is consumed only as far as the key: pass a file to read a local
/// model, or a cursor over a prefix to read one whose bytes came from
/// somewhere else.
pub fn name(read: &mut impl Read) -> Option<String> {
match find(read, |key| key == "general.name") {
Some((STRING, read)) => string(read),
_ => None,
}
}
/// Whether this model carries a multi-token-prediction head.
///
/// Worth asking because `llama-server` **exits** when told to use one that is
/// not there -- `--spec-type draft-mtp` on a plain model is "context type MTP
/// requested but model doesn't contain MTP layers" and then a server that
/// never comes up. So the flag can only be passed once this has said yes, and
/// a `false` here is the same answer as an unreadable file: don't ask for it.
///
/// Matched on the key's tail rather than its whole name, because the key is
/// prefixed with the architecture (`qwen35.nextn_predict_layers`) and the
/// architecture is whatever the next model is. The value is not read: a model
/// that declares the key at all is one whose tensors carry the head, and the
/// two disagreeing is a broken file rather than a state to handle.
pub fn has_mtp_head(read: &mut impl Read) -> bool {
find(read, |key| key.ends_with(".nextn_predict_layers")).is_some()
}
/// Steps through the metadata table to the first key `wanted` accepts,
/// returning its value's type tag and the reader positioned at the value.
fn find<R: Read>(read: &mut R, wanted: impl Fn(&str) -> bool) -> Option<(u32, &mut R)> {
let mut magic = [0u8; 4];
read.read_exact(&mut magic).ok()?;
if &magic != b"GGUF" {
return None;
}
let _version = u32s(read)?;
let _tensors = u64s(read)?;
let count = u64s(read)?;
for _ in 0..count {
let found = string(read)?;
let kind = u32s(read)?;
if wanted(&found) {
return Some((kind, read));
}
skip_value(kind, read)?;
}
None
}
// The value type tags, in the container's own numbering. Only the two this
// has to act on are named; the rest are widths, and `scalar_width` is where
// the numbering is written down once.
const STRING: u32 = 8;
const ARRAY: u32 = 9;
/// How many bytes a scalar of this type occupies, or `None` for a type that
/// is not a scalar -- which includes a tag this build does not know, since a
/// value of unknown length cannot be stepped over.
fn scalar_width(kind: u32) -> Option<u64> {
match kind {
// u8, i8, bool
0 | 1 | 7 => Some(1),
// u16, i16
2 | 3 => Some(2),
// u32, i32, f32
4..=6 => Some(4),
// u64, i64, f64
10..=12 => Some(8),
_ => None,
}
}
/// Steps over one value of `kind` without keeping it.
///
/// Recursive only in the sense that an array's elements are values; GGUF
/// arrays do not nest, so the recursion is one level deep by construction.
fn skip_value(kind: u32, read: &mut impl Read) -> Option<()> {
match kind {
STRING => {
let len = u64s(read)?;
skip(len, read)
}
ARRAY => {
let element = u32s(read)?;
let count = u64s(read)?;
match scalar_width(element) {
// The whole array at once: this is the tokenizer's scores and
// token types, and stepping over them one at a time is a
// syscall per token.
Some(width) => skip(count.checked_mul(width)?, read),
None if element == STRING => {
for _ in 0..count {
let len = u64s(read)?;
skip(len, read)?;
}
Some(())
}
// An array of arrays, or of something this build has no width
// for: the rest of the table can no longer be located.
None => None,
}
}
_ => skip(scalar_width(kind)?, read),
}
}
/// Discards `count` bytes, failing if the input ends first.
///
/// Chunked against a bounded buffer rather than read into a `Vec` of the
/// stated size: the sizes here come out of the file, and the file may be a
/// truncated prefix or not a GGUF at all.
fn skip(count: u64, read: &mut impl Read) -> Option<()> {
let mut scratch = [0u8; 8192];
let mut left = count;
while left > 0 {
let want = left.min(scratch.len() as u64) as usize;
read.read_exact(&mut scratch[..want]).ok()?;
left -= want as u64;
}
Some(())
}
fn string(read: &mut impl Read) -> Option<String> {
let len = u64s(read)?;
if len > MAX_STRING {
return None;
}
let mut bytes = vec![0u8; len as usize];
read.read_exact(&mut bytes).ok()?;
String::from_utf8(bytes).ok()
}
fn u32s(read: &mut impl Read) -> Option<u32> {
let mut bytes = [0u8; 4];
read.read_exact(&mut bytes).ok()?;
Some(u32::from_le_bytes(bytes))
}
fn u64s(read: &mut impl Read) -> Option<u64> {
let mut bytes = [0u8; 8];
read.read_exact(&mut bytes).ok()?;
Some(u64::from_le_bytes(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
/// Builds a GGUF header holding exactly these keys, so the parser is
/// tested against the layout rather than against a fixture nobody here
/// can regenerate.
fn header(entries: &[(&str, Value)]) -> Vec<u8> {
let mut out = Vec::from(*b"GGUF");
out.extend(3u32.to_le_bytes());
out.extend(0u64.to_le_bytes());
out.extend((entries.len() as u64).to_le_bytes());
for (key, value) in entries {
put_string(&mut out, key);
value.write(&mut out);
}
out
}
enum Value {
Str(&'static str),
U32(u32),
Strings(Vec<&'static str>),
Floats(Vec<f32>),
}
impl Value {
fn write(&self, out: &mut Vec<u8>) {
match self {
Self::Str(text) => {
out.extend(STRING.to_le_bytes());
put_string(out, text);
}
Self::U32(number) => {
out.extend(4u32.to_le_bytes());
out.extend(number.to_le_bytes());
}
Self::Strings(items) => {
out.extend(ARRAY.to_le_bytes());
out.extend(STRING.to_le_bytes());
out.extend((items.len() as u64).to_le_bytes());
for item in items {
put_string(out, item);
}
}
Self::Floats(items) => {
out.extend(ARRAY.to_le_bytes());
out.extend(6u32.to_le_bytes());
out.extend((items.len() as u64).to_le_bytes());
for item in items {
out.extend(item.to_le_bytes());
}
}
}
}
}
fn put_string(out: &mut Vec<u8>, text: &str) {
out.extend((text.len() as u64).to_le_bytes());
out.extend(text.as_bytes());
}
#[test]
fn the_name_is_read_past_every_other_kind_of_value() {
let bytes = header(&[
("general.architecture", Value::Str("qwen3")),
("general.file_type", Value::U32(7)),
("qwen3.attention.head_count", Value::U32(16)),
("tokenizer.ggml.scores", Value::Floats(vec![0.5; 64])),
("tokenizer.ggml.tokens", Value::Strings(vec!["a", "b", "c"])),
("general.name", Value::Str("Qwen3-0.6B")),
]);
assert_eq!(
name(&mut bytes.as_slice()),
Some("Qwen3-0.6B".to_string()),
"every value before the name has to be steppable over",
);
}
#[test]
/// The remote case: a prefix is all there is, and running off the end of
/// it is "we don't know" rather than a failure worth reporting. The
/// caller has the file name.
fn a_truncated_file_has_no_name_rather_than_failing() {
let bytes = header(&[
("tokenizer.ggml.tokens", Value::Strings(vec!["a", "b", "c"])),
("general.name", Value::Str("Qwen3-0.6B")),
]);
for cut in [4, 12, 24, bytes.len() - 4] {
assert_eq!(name(&mut &bytes[..cut]), None, "cut at {cut}");
}
}
#[test]
fn a_file_that_is_not_gguf_has_no_name() {
assert_eq!(name(&mut b"not a model at all".as_slice()), None);
assert_eq!(name(&mut b"".as_slice()), None);
}
#[test]
/// A name that is not a string is not a name. The alternative is
/// rendering a number as one, which reads as a model called "7".
fn a_name_of_the_wrong_type_is_not_read() {
let bytes = header(&[("general.name", Value::U32(7))]);
assert_eq!(name(&mut bytes.as_slice()), None);
}
#[test]
/// The head is found by the tail of the key, because the whole key is
/// prefixed with whatever architecture the model is.
fn an_mtp_head_is_found_whatever_the_architecture_is_called() {
let with = header(&[
("general.architecture", Value::Str("qwen35")),
("qwen35.block_count", Value::U32(64)),
("qwen35.nextn_predict_layers", Value::U32(1)),
]);
assert!(has_mtp_head(&mut with.as_slice()));
let without = header(&[
("general.architecture", Value::Str("qwen3")),
("qwen3.block_count", Value::U32(28)),
]);
assert!(!has_mtp_head(&mut without.as_slice()));
}
#[test]
/// A prefix that stops short says no, and that is the direction it has to
/// fail in: `--spec-type draft-mtp` on a model with no head is a server
/// that exits, so "we could not tell" and "it has none" both mean don't
/// ask for it.
fn a_truncated_file_reports_no_mtp_head() {
let bytes = header(&[("qwen35.nextn_predict_layers", Value::U32(1))]);
assert!(!has_mtp_head(&mut &bytes[..12]));
}
#[test]
/// The real thing, when this machine happens to have one. Skipped rather
/// than failed where it does not: the models directory is not part of the
/// checkout, and a test that needs gigabytes to run is one nobody runs.
fn a_real_model_on_this_machine_reads_back_its_name() {
let Some(home) = std::env::var_os("HOME") else {
return;
};
let dir = std::path::Path::new(&home).join(".local/share/ai-app/models");
let mut found = Vec::new();
collect_gguf(&dir, &mut found);
for path in found {
let mut file = std::fs::File::open(&path).expect("open");
let read = name(&mut file);
assert!(
read.is_some_and(|name| !name.trim().is_empty()),
"{} has a name in it and this did not read one",
path.display(),
);
}
}
fn collect_gguf(dir: &std::path::Path, found: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_gguf(&path, found);
} else if path.extension().is_some_and(|e| e == "gguf") {
found.push(path);
}
}
}
}
+162 -11
View File
@@ -4,7 +4,7 @@
//! itself which of the known programs it has, and the answer becomes its //! itself which of the known programs it has, and the answer becomes its
//! providers. That is a security property, not a convenience: **no route accepts //! providers. That is a security property, not a convenience: **no route accepts
//! a command from the phone.** If it did, the enrolled token could introduce //! a command from the phone.** If it did, the enrolled token could introduce
//! arbitrary programs to run on every machine a setup names. //! arbitrary programs to run on every machine already configured here.
//! //!
//! It is also the better interface: nobody wants to type an absolute path on a //! It is also the better interface: nobody wants to type an absolute path on a
//! phone keyboard, and a machine that has moved its binaries answers correctly //! phone keyboard, and a machine that has moved its binaries answers correctly
@@ -14,7 +14,9 @@
//! is editing `config.ron` on the backend, which is exactly the authority the //! is editing `config.ron` on the backend, which is exactly the authority the
//! phone is not being given. //! phone is not being given.
use anyhow::Result; use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{Value, json};
use crate::config::{DriverKind, ProviderConfig}; use crate::config::{DriverKind, ProviderConfig};
use crate::session::transport::{Launch, Transport}; use crate::session::transport::{Launch, Transport};
@@ -25,7 +27,11 @@ use crate::session::transport::{Launch, Transport};
/// session stores. /// session stores.
const PROBES: &[(&str, &str, DriverKind)] = &[ const PROBES: &[(&str, &str, DriverKind)] = &[
("claude-cli", "claude", DriverKind::ClaudeCli), ("claude-cli", "claude", DriverKind::ClaudeCli),
("local-llama", "llama-server", DriverKind::LlamaCpp), ("codex-cli", "codex", DriverKind::CodexCli),
// Named for the program rather than for where it runs: it runs
// wherever the machine is, and "local" was true only while a llama
// session could not be spawned on another machine.
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
]; ];
/// Models offered for a discovered Claude CLI. A shortcut list for the spawn /// Models offered for a discovered Claude CLI. A shortcut list for the spawn
@@ -52,12 +58,7 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
// and nowhere else. Offering it on a remote machine would be a choice that // and nowhere else. Offering it on a remote machine would be a choice that
// changes nothing. // changes nothing.
if matches!(transport, Transport::Here) { if matches!(transport, Transport::Here) {
providers.push(ProviderConfig { providers.push(crate::config::Config::echo_provider());
name: crate::config::ECHO_PROVIDER.to_string(),
kind: DriverKind::Echo,
command: None,
models: Vec::new(),
});
} }
for (name, binary, kind) in PROBES { for (name, binary, kind) in PROBES {
let path = found let path = found
@@ -78,11 +79,139 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(), DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
_ => Vec::new(), _ => Vec::new(),
}, },
mcp_servers: mcp_defaults(*kind),
// What a probe cannot know: how this machine's models are loaded
// is configured after the fact, and a re-probe keeps it -- see
// `SessionManager::update_machine`.
model_settings: Default::default(),
max_loaded: None,
}); });
} }
Ok(providers) Ok(providers)
} }
/// One model a picker can offer, and what to call it there.
///
/// Two fields rather than one string because for one provider they differ:
/// a llama.cpp model is chosen by the path it lives at and read as the name
/// its own metadata gives it. Every other provider's id is already the name,
/// and says so by repeating it -- which is what keeps the picker free of a
/// branch on the session kind.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OfferedModel {
/// What a spawn or a model change is given. Opaque to the phone.
pub id: String,
/// What a person reads on the chip.
pub label: String,
}
impl OfferedModel {
/// A model whose id is its own name, which is every provider but llama.
fn plain(id: impl Into<String>) -> Self {
let id = id.into();
Self {
label: id.clone(),
id,
}
}
}
/// The MCP servers a newly discovered provider of this kind starts with.
///
/// A default rather than something to be typed in: a llama session with no web
/// search is the state somebody would then have to find out how to leave, and
/// Exa is what llama.cpp's own web UI offers under the same name. It is an
/// ordinary config entry once written, so removing it is deleting a line.
///
/// Only llama.cpp, because only a driver that runs its own agent loop can use
/// one -- the coding CLIs configure MCP themselves and a second answer here
/// would quietly disagree with theirs.
fn mcp_defaults(kind: DriverKind) -> Vec<crate::config::McpServerConfig> {
match kind {
DriverKind::LlamaCpp => vec![crate::config::McpServerConfig {
name: "exa".to_string(),
url: crate::session::llama::EXA_MCP_URL.to_string(),
}],
_ => Vec::new(),
}
}
/// Models the selected provider currently offers on this machine.
///
/// Codex's catalog is account- and CLI-version-specific, so it is asked at the
/// moment the picker opens rather than copied into `config.ron`. A llama.cpp
/// provider offers the GGUFs on the machine it runs on, through this same call
/// -- there was a second route answering that alone, and it went when this one
/// learned to, because a picker offering a model the spawn screen does not, or
/// naming it differently, is two answers to one question. Other providers
/// retain the shortcut list discovery stored for them.
pub async fn provider_models(
transport: &Transport,
provider: &ProviderConfig,
models_dir: &std::path::Path,
) -> Result<Vec<OfferedModel>> {
if provider.kind == DriverKind::LlamaCpp {
let dir = crate::models::dir_on(transport, models_dir);
let found = crate::models::on_machine(transport, &dir).await?;
let labels = crate::models::labels(&found);
return Ok(found
.into_iter()
.zip(labels)
.map(|(model, label)| OfferedModel {
id: model.key,
label,
})
.collect());
}
if provider.kind != DriverKind::CodexCli {
return Ok(provider.models.iter().map(OfferedModel::plain).collect());
}
let transport = transport.clone();
let program = provider.program().to_string();
tokio::task::spawn_blocking(move || {
let launch = Launch::new(program, vec!["app-server".into(), "--stdio".into()], None);
let initial = json!({
"id": 1,
"method": "initialize",
"params": {"clientInfo": {"name": "ai-app", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")}}
});
let requests = [
json!({"method": "initialized"}),
json!({"id": 2, "method": "model/list", "params": {"includeHidden": false, "limit": 100}}),
];
let answer = transport.request_json_blocking(&launch, &initial, &requests, 2)?;
Ok(parse_codex_models(&answer)?
.into_iter()
.map(OfferedModel::plain)
.collect())
})
.await?
}
fn parse_codex_models(answer: &Value) -> Result<Vec<String>> {
if let Some(message) = answer.pointer("/error/message").and_then(Value::as_str) {
anyhow::bail!("Codex could not list models: {message}");
}
let entries = answer
.pointer("/result/data")
.and_then(Value::as_array)
.context("Codex returned no model catalog")?;
let mut models = entries
.iter()
.filter(|entry| {
!entry
.get("hidden")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.filter_map(|entry| entry.get("model").and_then(Value::as_str))
.map(str::to_string)
.collect::<Vec<_>>();
models.dedup();
Ok(models)
}
/// Adds what to do to failures whose own wording does not say. /// Adds what to do to failures whose own wording does not say.
/// ///
/// ssh's messages are written for someone at a terminal on the backend, which is /// ssh's messages are written for someone at a terminal on the backend, which is
@@ -112,7 +241,7 @@ fn explain(err: anyhow::Error) -> anyhow::Error {
} }
/// A short, stable, filename-safe id derived from a label. Derived once when a /// A short, stable, filename-safe id derived from a label. Derived once when a
/// setup is added and then fixed, so the label stays editable. Collisions are /// machine is added and then fixed, so the label stays editable. Collisions are
/// resolved by the caller, which is the only place that knows what exists. /// resolved by the caller, which is the only place that knows what exists.
pub fn id_from(label: &str) -> String { pub fn id_from(label: &str) -> String {
let slug: String = label let slug: String = label
@@ -174,7 +303,7 @@ pub fn shorten_home(path: &str) -> String {
/// ///
/// The common case of [`Transport::capture_with_input`]: nothing on stdin, a /// The common case of [`Transport::capture_with_input`]: nothing on stdin, a
/// failure reported as the machine's own words (ssh's "Permission denied" is the /// failure reported as the machine's own words (ssh's "Permission denied" is the
/// useful half of why a setup cannot be reached), and the output read as text /// useful half of why a machine cannot be reached), and the output read as text
/// because every caller here is asking a question whose answer is words. /// because every caller here is asking a question whose answer is words.
impl Transport { impl Transport {
pub async fn capture(&self, launch: &Launch) -> Result<String> { pub async fn capture(&self, launch: &Launch) -> Result<String> {
@@ -209,4 +338,26 @@ mod tests {
assert_eq!(shorten_home(&sibling), sibling); assert_eq!(shorten_home(&sibling), sibling);
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts"); assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
} }
#[test]
fn codex_models_are_the_selectable_non_hidden_catalog_entries() {
let answer = json!({"result": {"data": [
{"model": "gpt-small", "hidden": false},
{"model": "gpt-hidden", "hidden": true},
{"model": "gpt-large", "hidden": false}
]}});
assert_eq!(
parse_codex_models(&answer).unwrap(),
vec!["gpt-small".to_string(), "gpt-large".to_string()]
);
}
#[test]
fn a_failed_codex_catalog_is_not_reported_as_an_empty_one() {
let answer = json!({"error": {"message": "login required"}});
assert_eq!(
parse_codex_models(&answer).unwrap_err().to_string(),
"Codex could not list models: login required"
);
}
} }
+36 -17
View File
@@ -14,11 +14,14 @@
mod auth; mod auth;
mod config; mod config;
mod files; mod files;
mod gguf;
mod machines;
mod media; mod media;
mod models; mod models;
mod provider_auth;
mod resume;
mod routes; mod routes;
mod session; mod session;
mod setups;
mod ssh; mod ssh;
mod usage; mod usage;
@@ -41,7 +44,7 @@ use session::SessionManager;
const DEFAULT_PORT: u16 = 8443; const DEFAULT_PORT: u16 = 8443;
/// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app. /// Serves AI coding sessions (Codex, Claude Code, llama.cpp) to the phone app.
#[derive(Parser)] #[derive(Parser)]
struct Args { struct Args {
/// TLS port for the whole API surface. /// TLS port for the whole API surface.
@@ -142,7 +145,7 @@ async fn main() -> Result<()> {
let config_path = args let config_path = args
.config .config
.unwrap_or_else(|| config_home("ai-app").join("config.ron")); .unwrap_or_else(|| config_home("ai-app").join("config.ron"));
// Before the manager exists, on purpose: constructing it and seeding setups // Before the manager exists, on purpose: constructing it and seeding machines
// touches sessions and subprocesses this invocation has no business with // touches sessions and subprocesses this invocation has no business with
// while another instance is serving. Only the hash reaches disk, in the // while another instance is serving. Only the hash reaches disk, in the
// spool `auth.rs` reads; the link goes to stdout alone. // spool `auth.rs` reads; the link goes to stdout alone.
@@ -172,7 +175,6 @@ async fn main() -> Result<()> {
let models_dir = args let models_dir = args
.models_dir .models_dir
.unwrap_or_else(|| data_home("ai-app").join("models")); .unwrap_or_else(|| data_home("ai-app").join("models"));
let models = Arc::new(models::ModelStore::new(models_dir.clone()));
let manager = Arc::new( let manager = Arc::new(
SessionManager::new(config_path.clone(), data_dir, models_dir.clone()) SessionManager::new(config_path.clone(), data_dir, models_dir.clone())
.with_context(|| format!("failed to load {}", config_path.display()))? .with_context(|| format!("failed to load {}", config_path.display()))?
@@ -187,19 +189,19 @@ async fn main() -> Result<()> {
// After construction rather than inside it: seeding asks this machine what // After construction rather than inside it: seeding asks this machine what
// it has, and a constructor that quietly runs a subprocess is a surprise to // it has, and a constructor that quietly runs a subprocess is a surprise to
// every caller including the tests. // every caller including the tests.
manager.seed_setup().await?; manager.seed_machine().await?;
tracing::info!("config: {}", config_path.display()); tracing::info!("config: {}", config_path.display());
tracing::info!("models: {}", models_dir.display()); tracing::info!("models: {}", models_dir.display());
for setup in manager.setups() { for machine in manager.machines() {
match &setup.ssh { match &machine.ssh {
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address), Some(ssh) => tracing::info!(" machine \"{}\" -> {}", machine.name, ssh.address),
// No parenthetical naming the local machine: the default setup is // No parenthetical naming the local machine: the default machine is
// *called* "this machine", and the line read "setup this machine // *called* "this machine", so repeating a local qualifier read
// (this machine)". // like a stutter.
None => tracing::info!(" setup \"{}\" runs here", setup.name), None => tracing::info!(" machine \"{}\" runs here", machine.name),
} }
for provider in &setup.providers { for provider in &machine.providers {
tracing::info!(" provider {} ({:?})", provider.name, provider.kind); tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
} }
} }
@@ -262,15 +264,31 @@ async fn main() -> Result<()> {
.context("failed to load TLS cert/key")?; .context("failed to load TLS cert/key")?;
// No providers listed here any more: which machines can be asked, and about // No providers listed here any more: which machines can be asked, and about
// what, comes from the setups at the moment the screen is opened -- so a // what, comes from the machines at the moment the screen is opened -- so a
// machine added from the phone reports its limits without a restart. // machine added from the phone reports its limits without a restart.
let monitor = Arc::new(usage::UsageMonitor::new()); // The fixture is the manager's, because that is where the `/usage` command
// that sets it is typed; the monitor is what serves it.
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
let provider_logins = Arc::new(provider_auth::LoginManager::new(Arc::clone(&monitor)));
// The one thing in here that acts without a request behind it: a session
// switched to auto-resume waits out its account's usage limit and picks
// itself back up. Started whether or not any session has it on, because
// the setting is per session and changes from the phone -- see
// `resume::run`.
tokio::spawn(resume::run(Arc::clone(&manager), Arc::clone(&monitor)));
// The bearer-token middleware wraps the entire router -- routes and fallback // The bearer-token middleware wraps the entire router -- routes and fallback
// alike -- here and only here, so a new route can't forget auth. // alike -- here and only here, so a new route can't forget auth.
let app = routes::router(Arc::clone(&manager)) let app = routes::router(Arc::clone(&manager))
.merge(routes::usage_router(monitor, Arc::clone(&manager))) .merge(routes::usage_router(
.merge(routes::models_router(Arc::clone(&models))) Arc::clone(&monitor),
Arc::clone(&manager),
))
.merge(routes::provider_auth_router(
Arc::clone(&provider_logins),
Arc::clone(&manager),
))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
Arc::clone(&manager), Arc::clone(&manager),
auth::require_token, auth::require_token,
@@ -312,6 +330,7 @@ async fn main() -> Result<()> {
// above: a throwaway session is one nobody meant to keep, and the whole point // above: a throwaway session is one nobody meant to keep, and the whole point
// is that nothing has to remember to clean it up. // is that nothing has to remember to clean it up.
manager.stop_throwaway_sessions(); manager.stop_throwaway_sessions();
provider_logins.cancel_all();
manager.detach_all(); manager.detach_all();
Ok(()) Ok(())
+533 -472
View File
File diff suppressed because it is too large. Load diff
+470
View File
@@ -0,0 +1,470 @@
//! Interactive provider login carried between a CLI on a configured machine
//! and the phone. The CLI remains the only credential writer: this layer keeps
//! its short-lived process and relays only the authorization URL and the code
//! a person copies back from the browser.
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::process::Stdio;
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use rand::Rng;
use serde::Serialize;
use crate::config::{MachineConfig, ProviderConfig};
use crate::session::transport::Transport;
use crate::usage::UsageMonitor;
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const AUTHORIZATION_URL_TIMEOUT: Duration = Duration::from_secs(15);
const OUTPUT_POLL: Duration = Duration::from_millis(50);
const START_WAIT: Duration = Duration::from_secs(16);
type Key = (String, String);
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum LoginState {
Starting,
WaitingForCode {
#[serde(rename = "authorizationUrl")]
authorization_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
Submitting,
Succeeded,
Failed {
detail: String,
},
Cancelled,
}
impl LoginState {
fn terminal(&self) -> bool {
matches!(
self,
Self::Succeeded | Self::Failed { .. } | Self::Cancelled
)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginInfo {
pub attempt: String,
#[serde(flatten)]
pub state: LoginState,
}
#[derive(Clone)]
struct Attempt {
id: String,
state: Arc<Mutex<LoginState>>,
input: mpsc::Sender<Input>,
}
impl Attempt {
fn info(&self) -> LoginInfo {
LoginInfo {
attempt: self.id.clone(),
state: self.state.lock().unwrap().clone(),
}
}
}
enum Input {
Code(String),
Cancel,
}
/// The active login per machine and provider. Completed attempts stay until a
/// new one replaces them, so a phone that briefly loses its connection can ask
/// how the operation ended rather than being handed an ambiguous 404.
pub struct LoginManager {
attempts: Mutex<HashMap<Key, Attempt>>,
usage: Arc<UsageMonitor>,
}
impl LoginManager {
pub fn new(usage: Arc<UsageMonitor>) -> Self {
Self {
attempts: Mutex::new(HashMap::new()),
usage,
}
}
pub fn start(&self, machine: MachineConfig, provider: ProviderConfig) -> LoginInfo {
let provider_key = provider
.kind
.usage_provider()
.expect("a login route only accepts a metered provider")
.to_string();
let key = (machine.id.clone(), provider_key);
let mut attempts = self.attempts.lock().unwrap();
if let Some(attempt) = attempts.get(&key)
&& !attempt.state.lock().unwrap().terminal()
{
return attempt.info();
}
let id = attempt_id();
let state = Arc::new(Mutex::new(LoginState::Starting));
let (input, commands) = mpsc::channel();
let attempt = Attempt {
id: id.clone(),
state: Arc::clone(&state),
input,
};
attempts.insert(key, attempt.clone());
drop(attempts);
// Seed the cache before the worker takes the gate. A usage request in
// that small handoff window sees a fresh, truthful state and cannot
// start a second CLI against the same credential.
let gate = self.usage.claude_authentication_started(&machine);
let usage = Arc::clone(&self.usage);
std::thread::spawn(move || {
let _guard = gate.lock().unwrap();
run_login(&machine, &provider, commands, &state);
usage.claude_authentication_finished(&machine.id);
});
attempt.info()
}
pub fn wait_until_ready(&self, machine: &str, provider: &str, attempt: &str) -> LoginInfo {
let started = Instant::now();
loop {
let info = self.read(machine, provider, attempt).unwrap_or(LoginInfo {
attempt: attempt.to_string(),
state: LoginState::Failed {
detail: "the sign-in attempt disappeared".to_string(),
},
});
if !matches!(info.state, LoginState::Starting) || started.elapsed() >= START_WAIT {
return info;
}
std::thread::sleep(OUTPUT_POLL);
}
}
pub fn read(&self, machine: &str, provider: &str, attempt: &str) -> Option<LoginInfo> {
let attempts = self.attempts.lock().unwrap();
let found = attempts.get(&(machine.to_string(), provider.to_string()))?;
(found.id == attempt).then(|| found.info())
}
pub fn submit(
&self,
machine: &str,
provider: &str,
attempt: &str,
code: &str,
) -> Result<LoginInfo> {
let code = valid_code(code)?;
let attempts = self.attempts.lock().unwrap();
let found = attempts
.get(&(machine.to_string(), provider.to_string()))
.filter(|found| found.id == attempt)
.context("no such sign-in attempt")?;
if found.state.lock().unwrap().terminal() {
return Ok(found.info());
}
*found.state.lock().unwrap() = LoginState::Submitting;
if found.input.send(Input::Code(code.to_string())).is_err() {
*found.state.lock().unwrap() = LoginState::Failed {
detail: "the sign-in process has stopped".to_string(),
};
anyhow::bail!("the sign-in process has stopped");
}
Ok(found.info())
}
pub fn cancel(&self, machine: &str, provider: &str, attempt: &str) -> Result<LoginInfo> {
let attempts = self.attempts.lock().unwrap();
let found = attempts
.get(&(machine.to_string(), provider.to_string()))
.filter(|found| found.id == attempt)
.context("no such sign-in attempt")?;
if !found.state.lock().unwrap().terminal() {
let _ = found.input.send(Input::Cancel);
}
Ok(found.info())
}
/// Interactive helpers are unlike sessions: nothing adopts them after a
/// server restart. End every one while the process is still here to reap
/// the child it launched.
pub fn cancel_all(&self) {
let attempts = self.attempts.lock().unwrap();
for attempt in attempts.values() {
if !attempt.state.lock().unwrap().terminal() {
let _ = attempt.input.send(Input::Cancel);
}
}
let pending: Vec<_> = attempts
.values()
.map(|attempt| Arc::clone(&attempt.state))
.collect();
drop(attempts);
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline
&& pending
.iter()
.any(|state| !state.lock().unwrap().terminal())
{
std::thread::sleep(OUTPUT_POLL);
}
}
}
fn run_login(
machine: &MachineConfig,
provider: &ProviderConfig,
commands: mpsc::Receiver<Input>,
state: &Arc<Mutex<LoginState>>,
) {
if let Err(err) = run_login_inner(machine, provider, commands, state) {
*state.lock().unwrap() = LoginState::Failed {
detail: format!("couldn't sign in to Claude on {}: {err:#}", machine.name),
};
}
}
fn run_login_inner(
machine: &MachineConfig,
provider: &ProviderConfig,
commands: mpsc::Receiver<Input>,
state: &Arc<Mutex<LoginState>>,
) -> Result<()> {
let transport = Transport::for_machine(machine);
let args = vec![
"BROWSER=/bin/false".to_string(),
provider.program().to_string(),
"auth".to_string(),
"login".to_string(),
"--claudeai".to_string(),
];
let host = match &transport {
Transport::Here => None,
Transport::Ssh { ssh, .. } => Some(ssh),
};
let mut command = crate::ssh::command(host, "env", &args, None, None);
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.with_context(|| format!("couldn't run {} auth login", provider.program()))?;
let mut stdin = child
.stdin
.take()
.context("the login process has no stdin")?;
let stdout = child
.stdout
.take()
.context("the login process has no stdout")?;
let stderr = child
.stderr
.take()
.context("the login process has no stderr")?;
let (output, lines) = mpsc::channel();
read_lines(stdout, output.clone());
read_lines(stderr, output);
let started = Instant::now();
let mut authorization_url = None;
let mut last_line = None;
loop {
while let Ok(line) = lines.try_recv() {
if let Some(url) = authorization_url_in(&line) {
authorization_url = Some(url.to_string());
*state.lock().unwrap() = LoginState::WaitingForCode {
authorization_url: url.to_string(),
detail: None,
};
} else if line.to_ascii_lowercase().contains("invalid code") {
if let Some(url) = &authorization_url {
*state.lock().unwrap() = LoginState::WaitingForCode {
authorization_url: url.clone(),
detail: Some(
"That code was not accepted. Copy the complete code and try again."
.to_string(),
),
};
}
} else if !line.trim().is_empty() {
last_line = Some(line.trim().chars().take(500).collect::<String>());
}
}
match commands.recv_timeout(OUTPUT_POLL) {
Ok(Input::Code(code)) => {
*state.lock().unwrap() = LoginState::Submitting;
writeln!(stdin, "{code}").context("couldn't send the login code")?;
stdin.flush().context("couldn't send the login code")?;
}
Ok(Input::Cancel) => {
let _ = child.kill();
let _ = child.wait();
*state.lock().unwrap() = LoginState::Cancelled;
return Ok(());
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("the phone disconnected from the sign-in attempt");
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if let Some(status) = child
.try_wait()
.context("couldn't check the login process")?
{
*state.lock().unwrap() = if status.success() {
LoginState::Succeeded
} else {
LoginState::Failed {
detail: last_line
.unwrap_or_else(|| format!("Claude's login process exited with {status}")),
}
};
return Ok(());
}
if authorization_url.is_none() && started.elapsed() >= AUTHORIZATION_URL_TIMEOUT {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("the Claude CLI did not provide an authorization URL");
}
if started.elapsed() >= LOGIN_TIMEOUT {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("the sign-in attempt expired; start it again");
}
}
}
fn read_lines(reader: impl Read + Send + 'static, output: mpsc::Sender<String>) {
std::thread::spawn(move || {
for line in BufReader::new(reader).lines().map_while(Result::ok) {
let _ = output.send(line);
}
});
}
fn authorization_url_in(line: &str) -> Option<&str> {
let start = line.find("https://")?;
let tail = &line[start..];
let end = tail
.find(|character: char| character.is_whitespace() || character == '\u{1b}')
.unwrap_or(tail.len());
let url = &tail[..end];
(url.starts_with("https://claude.com/") || url.starts_with("https://platform.claude.com/"))
.then_some(url)
}
fn valid_code(code: &str) -> Result<&str> {
let code = code.trim();
anyhow::ensure!(!code.is_empty(), "the login code is empty");
anyhow::ensure!(code.len() <= 4096, "the login code is too long");
anyhow::ensure!(
!code.chars().any(char::is_control),
"the login code contains a line break or control character"
);
Ok(code)
}
fn attempt_id() -> String {
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DriverKind;
#[test]
fn extracts_only_anthropics_https_login_url() {
assert_eq!(
authorization_url_in("visit: https://claude.com/cai/oauth/authorize?state=x"),
Some("https://claude.com/cai/oauth/authorize?state=x")
);
assert!(authorization_url_in("visit: http://claude.com/nope").is_none());
assert!(authorization_url_in("visit: https://example.com/nope").is_none());
}
#[test]
fn login_code_is_one_bounded_line() {
assert_eq!(valid_code(" abc#state ").unwrap(), "abc#state");
assert!(valid_code("\n").is_err());
assert!(valid_code("a\nb").is_err());
assert!(valid_code(&"x".repeat(4097)).is_err());
}
#[cfg(unix)]
#[test]
fn relays_a_headless_cli_login_without_taking_over_its_credentials() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let cli = dir.path().join("fake-claude");
std::fs::write(
&cli,
"#!/bin/sh\necho 'https://claude.com/cai/oauth/authorize?state=test'\nIFS= read -r code\n[ \"$code\" = 'the-code' ]\n",
)
.expect("write fake CLI");
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o700))
.expect("make fake CLI executable");
let monitor = Arc::new(UsageMonitor::new(Default::default()));
let logins = LoginManager::new(monitor);
let machine = MachineConfig {
id: "vm".to_string(),
name: "test vm".to_string(),
ssh: None,
providers: Vec::new(),
};
let provider = ProviderConfig {
name: "claude-cli".to_string(),
kind: DriverKind::ClaudeCli,
command: Some(cli.display().to_string()),
models: Vec::new(),
mcp_servers: Vec::new(),
model_settings: Default::default(),
max_loaded: None,
};
let started = logins.start(machine, provider);
let ready = logins.wait_until_ready("vm", "claude", &started.attempt);
assert!(matches!(ready.state, LoginState::WaitingForCode { .. }));
let wire = serde_json::to_value(&ready).expect("serialize login state");
assert!(wire.get("authorizationUrl").is_some(), "{wire}");
assert!(wire.get("authorization_url").is_none(), "{wire}");
let submitted = logins
.submit("vm", "claude", &started.attempt, "the-code")
.expect("submit code");
assert!(matches!(submitted.state, LoginState::Submitting));
let deadline = Instant::now() + Duration::from_secs(2);
loop {
let finished = logins
.read("vm", "claude", &started.attempt)
.expect("attempt remains readable");
if matches!(finished.state, LoginState::Succeeded) {
break;
}
assert!(
Instant::now() < deadline,
"login did not finish: {finished:?}"
);
std::thread::sleep(OUTPUT_POLL);
}
}
}
+385
View File
@@ -0,0 +1,385 @@
//! Auto-resume: picking a session back up when its account's usage limit
//! lifts.
//!
//! Off unless a session was switched to it, because this spends quota the
//! moment quota exists and does it while nobody is watching. What it does is
//! narrow on purpose: it sends one message -- "continue" unless something else
//! was typed -- to a session that stopped because the account ran out, and
//! then it is done. There is no retry loop around the conversation itself.
//!
//! **The schedule is a plan to ask, never a plan to send.** A reset time is
//! the one thing here that cannot be trusted: the dialect's is a hint written
//! when the turn failed, the endpoint's moves when the window moves, and both
//! are wrong across the case this exists for -- a limit that lifts later than
//! it said. So the wait ends in a *question* to [`crate::usage`], and only an
//! answer that says the limits no longer apply sends anything. Every other
//! answer, including one that cannot be got at all, becomes a new wait.
//!
//! This is the top layer: it holds the session manager and the usage monitor
//! and neither holds it. That is what lets the decision below be a pure
//! function of a snapshot and a clock, which is the whole of what is worth
//! testing here.
use std::sync::Arc;
use std::time::Duration;
use crate::session::{LimitHit, OwedResume, SessionManager, now};
use crate::usage::{UsageMonitor, UsageSnapshot, UsageState};
/// How often to look at the schedule. Coarse deliberately: a wait measured in
/// hours does not deserve a fine-grained clock, and the meter behind it is
/// cached for three minutes anyway.
const TICK: Duration = Duration::from_secs(60);
/// How close to a scheduled check is close enough to ask the meter. Anything
/// further out is left alone, so a session waiting five hours costs nothing
/// until the last few minutes of it.
const NEARLY: f64 = 300.0;
/// How long to wait after an answer that decided nothing -- the machine could
/// not be asked, or it says the limit is still on with no reset time.
const BACKOFF: f64 = 300.0;
/// The least time to wait before asking again, whatever a reset time says. A
/// window that claims to reset in the past would otherwise be asked about on
/// every tick.
const AT_LEAST: f64 = 60.0;
/// How long after the limit was hit to stop waiting.
///
/// Something has to bound it, or a machine that can never be asked -- an
/// unplugged laptop, a machine somebody edited away -- is retried for ever with
/// nothing on screen saying so. A day is past the longest window Claude
/// reports, so reaching this means the wait was never going to end on its own.
const GIVE_UP: f64 = 24.0 * 60.0 * 60.0;
/// The percentage at which a window is spent. The API counts up to 100, so
/// this is an equality in all but name; written as a threshold because a
/// figure arriving slightly over is a full window, not a corrupt one.
const SPENT: f64 = 100.0;
/// What to do about one owed resume, having asked the meter.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Step {
/// The limits no longer apply: send the message.
Send,
/// Ask again at this epoch second.
WaitUntil(f64),
/// This has been waiting longer than anything real would take.
GiveUp,
}
/// Runs the schedule until the server stops.
///
/// Two things wake it: the tick, and a session reporting that it has just run
/// out. The second is not an optimisation -- a limit hit is what *creates* a
/// schedule, and a tick that happened a moment before it would otherwise leave
/// the session unrecorded until the next one.
pub async fn run(manager: Arc<SessionManager>, monitor: Arc<UsageMonitor>) {
let mut limits = manager.subscribe_limits();
loop {
tokio::select! {
_ = tokio::time::sleep(TICK) => {}
hit = limits.recv() => match hit {
Ok(LimitHit { session_id, resets_at }) => note(&manager, &session_id, resets_at),
// Lagged: some reports were dropped, and a session that hit a
// limit while this was busy has no schedule. Nothing is lost
// for good -- the sweep below reads the config, and the
// session will report again the next time it is poked -- but
// it is worth saying, because until then that session waits
// for a person.
Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => {
tracing::warn!("auto-resume missed {missed} limit reports");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
},
}
sweep(&manager, &monitor).await;
}
}
/// Records a limit against the session that hit it, if it is one that resumes.
pub(crate) fn note(manager: &SessionManager, session_id: &str, resets_at: Option<f64>) {
match manager.note_limit(session_id, resets_at) {
Ok(true) => tracing::info!("session {session_id} hit its usage limit; auto-resume is on"),
Ok(false) => {}
Err(err) => tracing::error!("couldn't schedule a resume for {session_id}: {err:#}"),
}
}
/// One pass over everything owed a message.
async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
let at = now();
for owed in manager.owed_resumes() {
if owed.scheduled.at - at > NEARLY {
continue;
}
// Asked per session rather than once for the whole sweep: the answer
// is cached per machine and per meter, so several sessions on one
// account share one fetch, and a machine nobody is waiting on is not
// dialled at all.
let snapshot = snapshot_for(Arc::clone(monitor), manager, &owed).await;
match decide(snapshot.as_ref(), &owed, now()) {
Step::Send => match manager.resume_now(&owed.session_id) {
Ok(message) => tracing::info!(
"the limit on {} has lifted; sent \"{message}\" to {}",
owed.machine,
owed.session_id
),
Err(err) => {
tracing::error!("couldn't resume {}: {err:#}", owed.session_id)
}
},
Step::WaitUntil(next) => {
if let Err(err) = manager.reschedule_resume(&owed.session_id, next) {
tracing::error!(
"couldn't move {}'s resume to {next}: {err:#}",
owed.session_id
);
}
}
Step::GiveUp => {
// About the machine rather than in the state's own words: the
// detail is in the log, and what lands in the transcript has
// to read on a phone.
let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) {
Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(),
_ => format!("{} could not be asked for a day", owed.machine),
};
if let Err(err) = manager.abandon_resume(&owed.session_id, &why) {
tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id);
}
}
}
}
}
/// The numbers for the machine and the meter this session is billed against,
/// and `None` when nothing reports on it.
///
/// Blocking work, so it goes to a blocking thread: the fetch behind it reads a
/// credential file over ssh and then makes an HTTP call.
async fn snapshot_for(
monitor: Arc<UsageMonitor>,
manager: &SessionManager,
owed: &OwedResume,
) -> Option<UsageSnapshot> {
let machines: Vec<_> = manager
.machines()
.into_iter()
.filter(|machine| machine.id == owed.machine)
.collect();
if machines.is_empty() {
return None;
}
let provider = owed.provider;
tokio::task::spawn_blocking(move || {
monitor
.snapshots(&machines)
.into_iter()
.find(|snapshot| snapshot.provider == provider)
})
.await
.unwrap_or_default()
}
/// What one owed resume should do, given what the meter said and the time.
///
/// A pure function of the two, which is what makes the rule inspectable: every
/// answer that is not "the limits no longer apply" is a longer wait, and the
/// only thing that ends the waiting other than success is the clock.
///
/// The reset time comes from the *snapshot* rather than from the schedule, so
/// a window that turns out to reset later than the dialect said pushes the
/// check back, and one that resets sooner pulls it forward. That is the case
/// the whole design is about: the first answer was a guess, this one is a
/// measurement.
pub fn decide(snapshot: Option<&UsageSnapshot>, owed: &OwedResume, at: f64) -> Step {
let step = match snapshot {
// The meter answered with numbers, which is the only answer that can
// send anything.
Some(snapshot) if snapshot.state == UsageState::Ok => {
let spent: Vec<&crate::usage::UsageWindow> = snapshot
.windows
.iter()
.filter(|window| window.percent >= SPENT)
.collect();
if spent.is_empty() {
Step::Send
} else {
// The earliest of the spent windows: it is the first moment
// the situation can have changed, and if the others are still
// full this comes straight back here.
match spent
.iter()
.filter_map(|window| epoch_of(window.resets_at.as_deref()))
.min_by(f64::total_cmp)
{
Some(resets) => Step::WaitUntil(resets),
// Spent with no reset time anybody could read. Not a
// reason to send: what is known is that the limit is on.
None => Step::WaitUntil(at + BACKOFF),
}
}
}
// Logged out, unreachable, or the endpoint refused us -- and nothing
// at all, which is a session whose machine or provider has gone. None
// of them says the limit has lifted, and sending on any of them is
// exactly the "inferred value presented as a measured one" this is
// built to avoid.
_ => Step::WaitUntil(at + BACKOFF),
};
match step {
// Waiting past the point where a real window would have reset means
// whatever is wrong is not going to fix itself.
Step::WaitUntil(_) if at - owed.scheduled.since > GIVE_UP => Step::GiveUp,
Step::WaitUntil(next) => Step::WaitUntil(next.max(at + AT_LEAST)),
other => other,
}
}
/// An RFC-3339 timestamp as epoch seconds, and `None` for one that is absent
/// or unreadable -- the same two answers the phone's countdown makes, kept
/// apart from each other nowhere here because both mean "this cannot decide
/// when to ask".
fn epoch_of(resets_at: Option<&str>) -> Option<f64> {
let text = resets_at?;
time::OffsetDateTime::parse(text, &time::format_description::well_known::Rfc3339)
.ok()
.map(|at| at.unix_timestamp() as f64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ScheduledResume;
use crate::usage::UsageWindow;
fn owed(since: f64) -> OwedResume {
OwedResume {
session_id: "s1".to_string(),
machine: "local".to_string(),
provider: crate::usage::CLAUDE,
scheduled: ScheduledResume { at: since, since },
}
}
fn snapshot(state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
UsageSnapshot {
provider: crate::usage::CLAUDE.to_string(),
machine: "local".to_string(),
machine_name: "this machine".to_string(),
limit_id: None,
limit_name: None,
state,
windows,
fetched_at: 0.0,
}
}
fn window(percent: f64, resets_at: Option<&str>) -> UsageWindow {
UsageWindow {
kind: "session".to_string(),
label: "5-hour window".to_string(),
percent,
duration_minutes: Some(300),
resets_at: resets_at.map(str::to_string),
active: true,
}
}
#[test]
fn a_meter_with_room_in_it_is_the_only_thing_that_sends() {
let clear = snapshot(UsageState::Ok, vec![window(41.0, None)]);
assert_eq!(decide(Some(&clear), &owed(0.0), 100.0), Step::Send);
}
#[test]
fn a_window_still_spent_moves_the_check_to_its_own_reset_time() {
// The case the feature exists for: the wait was scheduled for one
// time, the limit is still on, and the endpoint now names another.
let at = 1_788_546_972.0;
let later = "2026-09-05T12:00:00+00:00";
let spent = snapshot(UsageState::Ok, vec![window(100.0, Some(later))]);
assert_eq!(
decide(Some(&spent), &owed(at - 60.0), at),
Step::WaitUntil(epoch_of(Some(later)).expect("parses"))
);
}
#[test]
fn a_reset_time_already_past_still_waits_a_little() {
let at = 1_788_546_972.0;
let spent = snapshot(
UsageState::Ok,
vec![window(100.0, Some("2020-01-01T00:00:00+00:00"))],
);
assert_eq!(
decide(Some(&spent), &owed(at - 60.0), at),
Step::WaitUntil(at + AT_LEAST)
);
}
#[test]
fn the_earliest_spent_window_is_the_one_worth_waiting_on() {
let at = 1_788_546_972.0;
let soon = "2026-09-05T12:00:00+00:00";
let far = "2026-09-09T12:00:00+00:00";
let mut weekly = window(100.0, Some(far));
weekly.kind = "weekly_all".to_string();
let spent = snapshot(UsageState::Ok, vec![window(100.0, Some(soon)), weekly]);
assert_eq!(
decide(Some(&spent), &owed(at - 60.0), at),
Step::WaitUntil(epoch_of(Some(soon)).expect("parses"))
);
}
#[test]
fn a_meter_that_could_not_be_asked_never_sends() {
let at = 1_788_546_972.0;
for state in [
UsageState::NotLoggedIn,
UsageState::Unreachable {
detail: "no route".to_string(),
},
UsageState::Failed {
detail: "429".to_string(),
},
] {
let broken = snapshot(state.clone(), Vec::new());
assert_eq!(
decide(Some(&broken), &owed(at - 60.0), at),
Step::WaitUntil(at + BACKOFF),
"{state:?}"
);
}
// And no snapshot at all -- a machine or provider edited away under a
// session that was waiting on it.
assert_eq!(
decide(None, &owed(at - 60.0), at),
Step::WaitUntil(at + BACKOFF)
);
}
#[test]
fn waiting_longer_than_any_real_window_gives_up_rather_than_retrying_for_ever() {
let at = 1_788_546_972.0;
let broken = snapshot(
UsageState::Unreachable {
detail: "no route".to_string(),
},
Vec::new(),
);
assert_eq!(
decide(Some(&broken), &owed(at - GIVE_UP - 1.0), at),
Step::GiveUp
);
// A meter that answers is still allowed to send on the same tick: the
// ceiling bounds waiting, not resuming.
let clear = snapshot(UsageState::Ok, vec![window(3.0, None)]);
assert_eq!(
decide(Some(&clear), &owed(at - GIVE_UP - 1.0), at),
Step::Send
);
}
}
+1004 -193
View File
File diff suppressed because it is too large. Load diff
+278 -68
View File
@@ -47,7 +47,6 @@
//! same way as the rest, against 2.1.237 on 2026-08-29. //! same way as the rest, against 2.1.237 on 2026-08-29.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -57,8 +56,11 @@ use serde_json::{Value, json};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued}; use super::driver::{
AttachmentRef, BackgroundTask, Driver, Event, EventSink, SessionStatus, Unqueued,
};
use super::process; use super::process;
use super::subagent::Subagents;
use super::transport::{Launch, Streams, Transport}; use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig}; use crate::config::{ProviderConfig, SessionConfig};
use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call}; use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call};
@@ -213,8 +215,13 @@ impl ClaudeDriver {
transport: &Transport, transport: &Transport,
session_dir: &Path, session_dir: &Path,
sink: EventSink, sink: EventSink,
subagents: Arc<Subagents>,
initial_status: SessionStatus,
) -> Result<Self> { ) -> Result<Self> {
let state = Arc::new(Mutex::new(Translator::new(session_dir.to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
session_dir.to_path_buf(),
subagents,
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let reading = Arc::new(AtomicBool::new(true)); let reading = Arc::new(AtomicBool::new(true));
@@ -257,6 +264,9 @@ impl ClaudeDriver {
Self::start(meta, provider, transport, session_dir)? Self::start(meta, provider, transport, session_dir)?
} }
}; };
if !started_here && matches!(initial_status, SessionStatus::Idle | SessionStatus::Waiting) {
state.lock().unwrap().mark_settled();
}
// A process this driver has just started has been asked for nothing, // A process this driver has just started has been asked for nothing,
// which is what idle means. Said here because nothing else will: the // which is what idle means. Said here because nothing else will: the
@@ -316,14 +326,22 @@ impl ClaudeDriver {
format!("{} {}", provider.name, transport.describe()), format!("{} {}", provider.name, transport.describe()),
)); ));
Ok(Self { let driver = Self {
sink, sink,
queue, queue,
to_child, to_child,
state, state,
session_dir: session_dir.to_path_buf(), session_dir: session_dir.to_path_buf(),
reading, reading,
}) };
if !started_here {
// Claude 2.1.261 sends a full background-task snapshot after a
// repeated initialize. That repairs a completion edge missed by a
// backend that was down while the CLI kept running; older CLIs
// accept the request and simply send no snapshot.
driver.send_control(json!({"subtype": "initialize"}), None);
}
Ok(driver)
} }
/// Starts a new CLI for this session, with its streams in the session /// Starts a new CLI for this session, with its streams in the session
@@ -351,6 +369,12 @@ impl ClaudeDriver {
if let Some(mode) = &meta.permission_mode { if let Some(mode) = &meta.permission_mode {
push("--permission-mode", mode); push("--permission-mode", mode);
} }
// Launch-only: see `SessionConfig::effort`. Omitted entirely when
// unset, so the CLI's own default is what an unchosen session gets
// rather than a level this app decided to call the default.
if let Some(effort) = &meta.effort {
push("--effort", effort);
}
// Named at birth, so this session is the same session in the CLI's own // Named at birth, so this session is the same session in the CLI's own
// picker and in what other agents see. // picker and in what other agents see.
// //
@@ -382,11 +406,11 @@ impl ClaudeDriver {
// Fresh logs, because the offsets that index them start at zero and // Fresh logs, because the offsets that index them start at zero and
// everything the previous process said is already in the transcript. // everything the previous process said is already in the transcript.
let stdin = make_fifo(&session_dir.join(STDIN_FIFO))?; let stdin = process::make_fifo(&session_dir.join(STDIN_FIFO))?;
let stdout = create_log(&session_dir.join(STDOUT_LOG))?; let stdout = process::create_log(&session_dir.join(STDOUT_LOG))?;
let stderr = create_log(&session_dir.join(STDERR_LOG))?; let stderr = process::create_log(&session_dir.join(STDERR_LOG))?;
let program = provider.command.as_deref().unwrap_or("claude"); let program = provider.program();
let launch = Launch::new(program, args, meta.cwd.as_deref()); let launch = Launch::new(program, args, meta.cwd.as_deref());
let child = transport.spawn( let child = transport.spawn(
&launch, &launch,
@@ -488,12 +512,16 @@ impl ClaudeDriver {
} }
impl Driver for ClaudeDriver { impl Driver for ClaudeDriver {
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
self.state.lock().unwrap().background_tasks()
}
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) { fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
let mut content = Vec::new(); let mut content = Vec::new();
// An image goes into the message itself; the model looks at it. Any // An image goes into the message itself; the model looks at it. Any
// other file stays where the upload put it and the message says where, // other file stays where the upload put it and the message says where,
// because the CLI can read a file by path and a model cannot be handed // because the CLI can read a file by path and a model cannot be handed
// a trace any other way. Named after the text, so the words come first. // a trace any other way.
let mut files = Vec::new(); let mut files = Vec::new();
for id in &attachments { for id in &attachments {
let sent = if crate::media::media_type_for(id).is_some() { let sent = if crate::media::media_type_for(id).is_some() {
@@ -507,18 +535,6 @@ impl Driver for ClaudeDriver {
}); });
} }
} }
let mut body = text.clone();
for path in files {
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str(&format!("Attached file: {}", path.display()));
}
if !body.is_empty() {
content.push(json!({"type": "text", "text": body}));
}
let line =
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
let mut queue = self.queue.lock().unwrap(); let mut queue = self.queue.lock().unwrap();
// Saying so beats writing into a fifo that nothing is reading, which is // Saying so beats writing into a fifo that nothing is reading, which is
// what this used to do -- the message went nowhere and looked exactly // what this used to do -- the message went nowhere and looked exactly
@@ -531,6 +547,15 @@ impl Driver for ClaudeDriver {
}); });
return; return;
} }
// Built under the lock because whether this is a steer decides what the
// CLI is told, and the answer must be the same one the branch below acts
// on -- see `super::driver::message_body`.
let body = super::driver::message_body(&text, &files, queue.running);
if !body.is_empty() {
content.push(json!({"type": "text", "text": body}));
}
let line =
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
if queue.running { if queue.running {
// Into the running turn, now. Announced when the CLI shows it has // Into the running turn, now. Announced when the CLI shows it has
// been round the model again -- see `Queue`. // been round the model again -- see `Queue`.
@@ -792,11 +817,39 @@ async fn follow(
process::Liveness::Dead if complete > 0 => {} process::Liveness::Dead if complete > 0 => {}
process::Liveness::Dead => { process::Liveness::Dead => {
queue.lock().unwrap().close(&sink, "the session ended"); queue.lock().unwrap().close(&sink, "the session ended");
if !process::stopping(&session_dir) {
let detail = stderr_tail(&stderr_path); let detail = stderr_tail(&stderr_path);
// A token the CLI will not accept leaves the chat
// *unrecoverable* rather than merely failed: every later
// start passes the same `--resume` and dies the same way.
// So it is forgotten, and `Cleared` marks where the model's
// context stopped -- as for a Codex thread with no rollout.
let refused =
missing_conversation(&detail) && read_resume_token(&session_dir).is_some();
// Both the message and the divider follow the removal
// rather than the diagnosis: one that failed leaves the
// session as stuck as it was.
let recovered = refused && forget_resume_token(&session_dir);
if !detail.is_empty() { if !detail.is_empty() {
let _ = sink.send(Event::Error { // The CLI's sentence reads like the chat is lost when
message: format!("{label} exited:\n{detail}"), // one more message is all it needs, and this is the
}); // only place anyone sees it. Its words stay on top,
// being what a person would search for.
let message = if recovered {
format!(
"{label} exited:\n{detail}\n\nThat conversation is gone from the \
CLI, so this session has stopped trying to resume it. Send a \
message to carry on in a new one -- everything above is kept, \
but the model starts without it."
)
} else {
format!("{label} exited:\n{detail}")
};
let _ = sink.send(Event::Error { message });
}
if recovered {
let _ = sink.send(Event::Cleared);
}
} }
let _ = sink.send(Event::Status { let _ = sink.send(Event::Status {
state: SessionStatus::Exited, state: SessionStatus::Exited,
@@ -857,8 +910,12 @@ fn translate_line(
return true; return true;
}; };
let opens_a_model_call = starts_a_model_call(&message); let opens_a_model_call = starts_a_model_call(&message);
let parent_running = queue.lock().unwrap().running;
let (events, new_session_id, before) = { let (events, new_session_id, before) = {
let mut state = state.lock().unwrap(); let mut state = state.lock().unwrap();
if parent_running {
state.mark_running();
}
let before = state.session_id.clone(); let before = state.session_id.clone();
let events = state.translate(&message); let events = state.translate(&message);
let after = state.session_id.clone(); let after = state.session_id.clone();
@@ -927,10 +984,14 @@ fn translate_line(
{ {
return false; return false;
} }
// Either status the turn can end in -- see `SessionStatus::Waiting`.
// A turn that ended with a subagent still running is over for this
// queue's purposes: the CLI will read the next message, and holding
// one back until the subagent reported would sit on it indefinitely.
if matches!( if matches!(
event, event,
Event::Status { Event::Status {
state: SessionStatus::Idle state: SessionStatus::Idle | SessionStatus::Waiting
} }
) { ) {
// The case that must not be missed: a message written after the // The case that must not be missed: a message written after the
@@ -1034,44 +1095,6 @@ fn stderr_tail(path: &Path) -> String {
tail_of(&kept) tail_of(&kept)
} }
/// Creates the stdin fifo if it is not already there, and opens it read-write
/// for the process to inherit.
///
/// Read-write is the whole trick: a fifo opened read-only delivers EOF as soon
/// as the last writer closes, so the process would exit the moment this server
/// did -- exactly what leaving it running has to prevent. Holding it open for
/// writing means the process is its own last writer.
fn make_fifo(path: &Path) -> Result<std::fs::File> {
if !path.exists() {
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
.with_context(|| format!("{} is not a usable path", path.display()))?;
// SAFETY: a nul-terminated path this call only reads, and a mode with
// no bits the kernel can object to. Owner-only, like everything else in
// a session directory: this carries what the person typed.
let made = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
if made != 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("creating the fifo {}", path.display()));
}
}
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.with_context(|| format!("opening the fifo {}", path.display()))
}
/// A fresh, empty, owner-only log for one of the process's output streams.
fn create_log(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {}", path.display()))
}
pub(super) fn read_resume_token(session_dir: &Path) -> Option<String> { pub(super) fn read_resume_token(session_dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?; let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?;
serde_json::from_str::<Value>(&text) serde_json::from_str::<Value>(&text)
@@ -1088,6 +1111,38 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
} }
} }
/// Drop a resume token the CLI has refused, so the next start makes a session
/// rather than repeating the failure. Answers whether it is really gone: the
/// caller promises somebody the session has stopped resuming, and a failed
/// removal would make that a promise this server cannot keep.
fn forget_resume_token(session_dir: &Path) -> bool {
let path = session_dir.join(RESUME_FILE);
match std::fs::remove_file(&path) {
Ok(()) => true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
Err(err) => {
tracing::error!(
"couldn't forget the refused resume token at {}: {err}",
path.display()
);
false
}
}
}
/// Whether the CLI refused `--resume` because the token names nothing it has.
///
/// Two sentences say it and both recover the same way; matched on the phrases
/// that carry the fact, since the rest names the id. Measured against 2.1.237:
/// a valid UUID with nothing behind it gives the first, a non-UUID matching no
/// title the second.
fn missing_conversation(detail: &str) -> bool {
let detail = detail.to_ascii_lowercase();
detail.contains("no conversation found with session id")
|| (detail.contains("--resume requires a valid session id")
&& detail.contains("does not match any session title"))
}
/// Where an uploaded attachment is, as a path the CLI can be told. /// Where an uploaded attachment is, as a path the CLI can be told.
/// ///
/// Absolute, because the CLI's working directory is the session's and the /// Absolute, because the CLI's working directory is the session's and the
@@ -1163,7 +1218,10 @@ mod tests {
/// this" and "the transcript records that". /// this" and "the transcript records that".
fn events_from_lines(lines: &[&str]) -> Vec<Event> { fn events_from_lines(lines: &[&str]) -> Vec<Event> {
let dir = tempfile::tempdir().expect("temp dir"); let dir = tempfile::tempdir().expect("temp dir");
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let (sink, mut out) = mpsc::unbounded_channel::<Event>(); let (sink, mut out) = mpsc::unbounded_channel::<Event>();
for line in lines { for line in lines {
@@ -1187,7 +1245,10 @@ mod tests {
interject: impl FnOnce(&Arc<Mutex<Queue>>), interject: impl FnOnce(&Arc<Mutex<Queue>>),
) -> Vec<Event> { ) -> Vec<Event> {
let dir = tempfile::tempdir().expect("temp dir"); let dir = tempfile::tempdir().expect("temp dir");
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let (sink, mut out) = mpsc::unbounded_channel::<Event>(); let (sink, mut out) = mpsc::unbounded_channel::<Event>();
let mut interject = Some(interject); let mut interject = Some(interject);
@@ -1481,7 +1542,10 @@ mod tests {
// doing. // doing.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut received) = mpsc::unbounded_channel(); let (sink, mut received) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"working"}},"parent_tool_use_id":null}"#; let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"working"}},"parent_tool_use_id":null}"#;
@@ -1526,7 +1590,10 @@ mod tests {
// session back to work. // session back to work.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut received) = mpsc::unbounded_channel(); let (sink, mut received) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
queue.lock().unwrap().close(&sink, "the session ended"); queue.lock().unwrap().close(&sink, "the session ended");
@@ -1549,4 +1616,147 @@ mod tests {
assert!(received.try_recv().is_err()); assert!(received.try_recv().is_err());
assert!(queue.closed); assert!(queue.closed);
} }
/// Both refusals verbatim from 2.1.237, and the exits that must *not* be
/// read as one: a session that merely failed still has a conversation, and
/// forgetting its token would discard the model's context for nothing.
#[test]
fn both_refused_resume_tokens_are_recognised() {
assert!(missing_conversation(
"No conversation found with session ID: a1c6c855-86cd-4f47-8b50-eaea85be3579"
));
assert!(missing_conversation(
"Error: --resume requires a valid session ID or session title when used with \
--print. Usage: claude -p --resume <session-id|title>. Provided value \
\"not-a-real-session\" is not a UUID and does not match any session title."
));
assert!(!missing_conversation("Error: connection closed"));
assert!(!missing_conversation(
"Credit balance is too low to run this request"
));
// The usage line alone is a different complaint: `--resume` was passed
// wrongly, not given a token that named nothing.
assert!(!missing_conversation(
"Error: --resume requires a valid session ID or session title when used with --print."
));
}
/// The composition the reader performs, not just the matcher: `stderr_tail`
/// both truncates and trims, so the phrase can survive the CLI and still
/// not reach `missing_conversation`.
#[test]
fn the_refusal_survives_the_stderr_tail_that_carries_it() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("stderr.log");
// Verbatim from 2.1.237, trailing newline included -- a shell's error
// ends with one, which is exactly what `tail_of` exists to trim.
std::fs::write(
&path,
"No conversation found with session ID: a1c6c855-86cd-4f47-8b50-eaea85be3579\n",
)
.expect("write stderr");
assert!(missing_conversation(&stderr_tail(&path)));
// And still found when noise precedes it, since the refusal is the last
// thing the CLI writes and the tail is taken from the end.
let mut noisy = "some earlier warning\n".repeat(STDERR_LINES_KEPT * 2);
noisy.push_str("No conversation found with session ID: a1c6c855\n");
std::fs::write(&path, noisy).expect("write noisy stderr");
assert!(missing_conversation(&stderr_tail(&path)));
}
/// A dead process, a refusal in its stderr log, and a token on disk --
/// driven through the reader that performs the recovery rather than through
/// its parts, because the wiring is what the parts cannot check.
async fn exit_with_stderr(stderr: &str) -> (tempfile::TempDir, Vec<Event>) {
let dir = tempfile::tempdir().expect("tempdir");
// Empty but present: an unreadable stdout is a different path that
// returns before any of this.
std::fs::write(dir.path().join(STDOUT_LOG), "").expect("stdout log");
std::fs::write(dir.path().join(STDERR_LOG), stderr).expect("stderr log");
write_resume_token(dir.path(), "a1c6c855-86cd-4f47-8b50-eaea85be3579");
let (sink, mut events) = mpsc::unbounded_channel();
follow(
dir.path().to_path_buf(),
// No `/proc` entry, so `liveness()` reads `Dead` -- the state this
// arm exists for, without having to kill anything.
process::Record {
pid: u32::MAX,
started: 0,
detail: process::Detail::Stdio { stdout_read: 0 },
},
0,
Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
))),
sink,
Arc::new(Mutex::new(Queue::default())),
Arc::new(AtomicBool::new(true)),
"claude-cli on vm".to_string(),
)
.await;
let seen = std::iter::from_fn(|| events.try_recv().ok()).collect();
(dir, seen)
}
#[tokio::test]
async fn a_session_whose_conversation_vanished_is_left_able_to_start_again() {
let (dir, seen) = exit_with_stderr(
"No conversation found with session ID: a1c6c855-86cd-4f47-8b50-eaea85be3579\n",
)
.await;
let reported = seen
.iter()
.find_map(|event| match event {
Event::Error { message } => Some(message.clone()),
_ => None,
})
.expect("the exit is reported");
// The CLI's own words, and then what to do about them.
assert!(
reported.contains("No conversation found with session ID"),
"{reported}"
);
assert!(
reported.contains("Send a message to carry on"),
"{reported}"
);
// The divider, so the transcript says where the model's context ended.
assert!(seen.contains(&Event::Cleared), "{seen:?}");
// And the point of all of it: the next start has no token to repeat.
assert_eq!(read_resume_token(dir.path()), None);
}
/// The same reader on an exit that is *not* a refusal. The token is what
/// the session is still worth resuming from, so it has to survive.
#[tokio::test]
async fn an_ordinary_failure_keeps_the_token_it_can_still_resume_from() {
let (dir, seen) = exit_with_stderr("Error: connection closed\n").await;
assert!(!seen.contains(&Event::Cleared), "{seen:?}");
assert_eq!(
read_resume_token(dir.path()).as_deref(),
Some("a1c6c855-86cd-4f47-8b50-eaea85be3579")
);
}
#[test]
fn a_refused_token_is_forgotten_so_the_next_start_makes_a_session() {
let dir = tempfile::tempdir().expect("tempdir");
write_resume_token(dir.path(), "a1c6c855-86cd-4f47-8b50-eaea85be3579");
assert!(read_resume_token(dir.path()).is_some());
assert!(forget_resume_token(dir.path()));
// Gone, so `launch` pushes `--name` instead of `--resume` and the CLI
// is asked for a session it can actually make.
assert_eq!(read_resume_token(dir.path()), None);
// Removing an absent one succeeds too -- the check and the remove are
// separate, so the file can go between them.
assert!(forget_resume_token(dir.path()));
assert_eq!(read_resume_token(dir.path()), None);
}
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+381 -3
View File
@@ -15,6 +15,73 @@ use tokio::sync::mpsc;
/// renders them identically. /// renders them identically.
pub type ImageRef = String; pub type ImageRef = String;
/// Stores image bytes where the files route serves them and returns their transcript reference.
/// Both CLI dialects produce images in different envelopes; the durable file and naming rule are
/// part of the common event model and must not vary with that envelope.
pub(in crate::session) fn store_image(
session_dir: &std::path::Path,
media_type: &str,
bytes: &[u8],
) -> Option<ImageRef> {
let extension = crate::media::extension_for(media_type).unwrap_or("png");
let name = format!("{}.{extension}", super::random_hex());
let dir = session_dir.join("files");
if let Err(err) = wg_app_link::private::create_dir(&dir)
.map_err(std::io::Error::other)
.and_then(|()| std::fs::write(dir.join(&name), bytes))
{
tracing::error!("couldn't save produced image: {err}");
return None;
}
Some(name)
}
/// Prefixed to a message that was typed while a turn was already running.
///
/// A steer goes to the CLI the moment it arrives, but *when the model reads
/// it* is not ours to decide: it lands at the next model call if there is
/// one, and a turn that ends first delivers it as the opening line of the
/// next turn instead -- Claude's from the fifo, Codex's requeued after
/// `activeTurnNotSteerable`. Read there it looks like a reply to the answer
/// just given, so the model acts as if the person had seen that answer, which
/// is exactly what they had not. Nothing else distinguishes the two cases by
/// the time the model sees them, so the note is the only thing that can carry
/// the fact.
const STEERING_NOTE: &str = "[Sent while you were still working, so it was written without \
having seen the rest of that turn. Treat it as steering the work in progress, not as a \
reply to anything you said after it was sent.]";
/// The text a CLI receives for one message: the note above when this is a
/// steer, the typed words, and the paths of any attachment the model has to
/// read from disk rather than being handed inline.
///
/// The paths come after the words because a model reading a list of files
/// before the request treats the list as the request.
pub(in crate::session) fn message_body(
text: &str,
files: &[std::path::PathBuf],
steering: bool,
) -> String {
let mut body = String::new();
if steering {
body.push_str(STEERING_NOTE);
}
for part in std::iter::once(text.to_string()).chain(
files
.iter()
.map(|path| format!("Attached file: {}", path.display())),
) {
if part.is_empty() {
continue;
}
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str(&part);
}
body
}
/// The name an upload is stored and served under: an image is /// The name an upload is stored and served under: an image is
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file /// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what /// keeps its own name after the hex, `<hex>-<name>`, because the name is what
@@ -135,6 +202,44 @@ pub enum Event {
AssistantText { AssistantText {
delta: String, delta: String,
}, },
/// The authoritative text of the assistant message whose deltas immediately
/// precede this event.
///
/// Some providers stream a provisional rendering and revise it before the
/// item completes. This stays append-only like every other transcript
/// correction: readers replace the open message rather than editing an old
/// line, and replay therefore reaches the same text as the live stream.
AssistantTextFinal {
text: String,
},
/// The model's working, streamed the same way its reply is: the
/// reasoning it produced before -- or between -- the things it said.
///
/// Its own kind rather than [`Event::AssistantText`], because it is not
/// what the session said. The phone draws it as a card of its own, shut,
/// and no driver folds it back into the next prompt: a provider that
/// wants its own reasoning back sends it back itself.
///
/// Only a provider that actually streams its working sends this.
/// llama.cpp does, as `reasoning_content`; nothing is inferred for one
/// that does not, since a card that appeared whenever a turn was slow
/// would be a guess wearing a measurement's clothes.
Thinking {
delta: String,
},
/// The thinking immediately above this finished, having taken `ms`.
///
/// Measured by the driver rather than worked out by a reader from two
/// event timestamps. A reader only knows when an event *arrived*, so the
/// last delta of a block followed by a slow tool call is indistinguishable
/// from thinking that went on that long -- and a transcript replayed on a
/// phone has to reach the same figure the live stream showed.
///
/// A block with no end is one still being thought, which is what the card
/// draws a spinner for.
ThinkingDone {
ms: u64,
},
ToolStart { ToolStart {
id: String, id: String,
tool: String, tool: String,
@@ -208,6 +313,48 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
turn_start: Option<u64>, turn_start: Option<u64>,
}, },
/// **Retired on 2026-09-06, hours after it was added.** Kept only so the
/// transcripts written while it existed still read: a session that ran a
/// background task in that window has these lines for ever, and a
/// transcript is append-only, so there is no pass that could remove them.
///
/// Never constructed. It reported a background task finishing, and drawing
/// a row per one turned out to be a screen of notices about work the
/// reader was not asking after -- see PLAN.md's "Two turns must never be
/// drawn as one". The phone folds it to no row at all, which is what makes
/// keeping it cheap.
///
/// Deleting the variant instead is what broke every live session, and
/// [`Event::Unreadable`] is the reason that cannot happen again. This is
/// still here rather than left to that: an unreadable line draws a
/// placeholder, correctly, and one per background task is the same wall
/// the row was removed for.
TaskNote {
about: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
summary: Option<String>,
},
/// A line in a transcript that this build cannot read: a kind a newer
/// server wrote, a kind an older one wrote that has since been dropped, or
/// a line whose contents do not fit the kind it names.
///
/// **Only ever made when reading, never sent by a driver**, and it is the
/// reason a transcript can outlive a change to this enum. See
/// `Indexed::parse_at` for the incident: removing a variant after
/// transcripts had recorded it made every read of those files fail, so
/// every session in them lost its status, its history and its ability to
/// be sent to.
///
/// It carries the word the line called itself so a reader is told what
/// they are missing rather than that something is missing. `kind` is not
/// an enum for the obvious reason: the whole point of this variant is the
/// words that are not in one.
Unreadable {
kind: String,
},
/// The manager's record of a question being answered, so a rendered /// The manager's record of a question being answered, so a rendered
/// question card resolves on every device rather than only the one that /// question card resolves on every device rather than only the one that
/// answered. /// answered.
@@ -218,6 +365,14 @@ pub enum Event {
id: String, id: String,
answers: Vec<String>, answers: Vec<String>,
}, },
/// How many background tasks the provider says are alive now.
///
/// State rather than a transcript row: the phone draws it beside the
/// session status. A distinct event keeps the count current while a
/// session is open; `GET /sessions` supplies the opening snapshot.
BackgroundTasks {
count: usize,
},
Status { Status {
state: SessionStatus, state: SessionStatus,
}, },
@@ -236,6 +391,26 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
permission_mode: Option<String>, permission_mode: Option<String>,
}, },
/// How much context this session's model has to hold a conversation in.
///
/// The denominator the phone draws [`Event::UsageDelta`]'s `context`
/// against, and its own event rather than a field on that one because it
/// is not a per-turn measurement: it is fixed when the process starts and
/// changes only when a different one is started, which is what a model
/// change does. Reported the moment it is known, so the figure and what it
/// is out of arrive together rather than the first turn drawing a
/// numerator with no denominator.
///
/// **Only ever sent by a driver that actually knows.** A window nobody has
/// measured is not an unlimited one: llama.cpp answers it exactly, because
/// the number is a flag the server was started with and `/props` reads it
/// back, while a coding CLI's context is the vendor's business and
/// nothing in either control protocol states it. Those send nothing, the
/// session has no limit, and the phone draws the figure on its own -- see
/// `SessionSummary::context_limit`.
ContextWindow {
tokens: u64,
},
/// Per-turn token counts, where the dialect reports them. /// Per-turn token counts, where the dialect reports them.
UsageDelta { UsageDelta {
/// What this turn cost: the tokens it was charged for. /// What this turn cost: the tokens it was charged for.
@@ -252,6 +427,28 @@ pub enum Event {
/// able to draw. /// able to draw.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<u64>, context: Option<u64>,
/// How fast the reply came out, as the provider measured it.
///
/// `None` wherever nothing measured it, which is most providers: a
/// coding CLI reports what a turn cost and never how long the model
/// took over it, and dividing tokens by the wall time this server
/// waited would count the network, the tool calls and the reader's own
/// permission answers as generation. A figure that is right most of
/// the time is no use here, because nothing on screen could say which
/// times those were.
#[serde(default, skip_serializing_if = "Option::is_none")]
tokens_per_second: Option<f64>,
/// How long the provider spent reading the prompt before it began
/// answering -- see [`SessionStatus::Reading`], which is this while it
/// is happening.
///
/// The provider's own measurement or nothing, for the same reason
/// `tokens_per_second` above is: the wait this server watched also
/// contains the request and whatever else the machine was doing. It is worth reporting because it is the larger half of a
/// turn on a long conversation -- 22 seconds against 3 of generation,
/// measured on a 14,000-token prompt.
#[serde(default, skip_serializing_if = "Option::is_none")]
prefill_ms: Option<u64>,
}, },
/// A compaction that finished, and how much context it recovered. /// A compaction that finished, and how much context it recovered.
/// ///
@@ -302,11 +499,59 @@ pub enum Event {
/// it, which is why this is written down rather than left to be inferred /// it, which is why this is written down rather than left to be inferred
/// from a second example that does not exist. /// from a second example that does not exist.
Cleared, Cleared,
/// The account behind this session has no quota left, so the turn stopped
/// without finishing.
///
/// Its own event rather than an [`Event::Error`] carrying the dialect's
/// sentence, because two things act on it that cannot read English: the
/// transcript draws it as a state the session is in rather than as a
/// failure of something it did, and `crate::resume` schedules the message
/// that picks the work back up. Recognising it belongs to the driver, which
/// is the only layer that knows its dialect's wording -- above here nothing
/// matches on strings.
///
/// `resets_at` is epoch seconds, and `None` is a real state: the dialect
/// said the limit was hit without saying when it lifts. Nothing here
/// invents one -- what the wait is actually decided against is the usage
/// endpoint, and this is the hint that starts the waiting.
LimitReached {
#[serde(default, skip_serializing_if = "Option::is_none")]
resets_at: Option<f64>,
},
/// The provider refused the turn because its login can no longer be used.
///
/// Recognised by the driver for the same reason [`Event::LimitReached`] is:
/// only that layer knows the provider's dialect, and the phone needs a
/// state it can act on without matching error prose.
AuthenticationRequired {
message: String,
},
Error { Error {
message: String, message: String,
}, },
} }
/// The common presentation of a file change, whichever driver produced it.
pub(super) fn patch_start(id: String, diff: String) -> Event {
Event::ToolStart {
id,
tool: "Patch".to_string(),
input: serde_json::json!({"diff": diff}),
}
}
pub(super) fn prefixed_lines(prefix: char, text: &str) -> String {
text.split_inclusive('\n')
.map(|line| {
if line.ends_with('\n') {
format!("{prefix}{line}")
} else {
format!("{prefix}{line}\n")
}
})
.collect()
}
/// How much the model was holding, from the three figures a turn reports: /// How much the model was holding, from the three figures a turn reports:
/// the input side only, prompt plus both cache figures. A cached token is /// the input side only, prompt plus both cache figures. A cached token is
/// cheaper but it is still one the model was given; output is what the turn /// cheaper but it is still one the model was given; output is what the turn
@@ -319,6 +564,25 @@ pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
input + cache_creation + cache_read input + cache_creation + cache_read
} }
/// The context window after `event`, given what it was before.
///
/// Beside [`context_after`] because it has the same three readers and the same
/// hazard: a figure that outlives what made it true. A model change replaces
/// the process, so the window it reports replaces the old one -- and until the
/// new one says, there is no answer rather than the previous model's.
pub fn context_limit_after(current: Option<u64>, event: &Event) -> Option<u64> {
match event {
Event::ContextWindow { tokens } => Some(*tokens),
// The window belongs to the process, and a stopped one has none. Left
// standing, a restarted session on a different model would draw its
// occupancy against the previous model's window.
Event::Status {
state: SessionStatus::Exited,
} => None,
_ => current,
}
}
/// The context after `event`, given what it was before. /// The context after `event`, given what it was before.
/// ///
/// The whole rule in one place, because three readers need the same answer: /// The whole rule in one place, because three readers need the same answer:
@@ -389,6 +653,48 @@ pub enum SessionStatus {
Running, Running,
AwaitingInput, AwaitingInput,
Compacting, Compacting,
/// The session's process is up but cannot be spoken to yet.
///
/// Its own state because the two it would otherwise borrow are both
/// wrong in ways somebody notices. `Running` means the session is
/// answering, so a model taking a minute to load looks like a model
/// thinking for a minute -- and there is no way to tell from the screen
/// that the first message will be refused. `Idle` invites that message
/// and then loses it.
///
/// It exists for `llama-server`, which reads a multi-gigabyte file off
/// disk before it answers anything, and it is general because the
/// condition is: a process that is started and not yet ready is a state
/// any driver may have to report. Nothing is queued *because* of this
/// state -- a driver that reports it is responsible for holding what it
/// is sent until it can deliver it -- but this is what says so on screen.
Loading,
/// The model is reading what it was given, and has not begun answering.
///
/// Its own state for the same reason [`SessionStatus::Loading`] is, one
/// level down: prompt processing is work the machine does before a turn
/// produces anything, and on a long conversation it is the part of the
/// wait somebody is looking at. Reported as `Running` it was
/// indistinguishable from a model thinking, which is what the reader is
/// actually waiting to see.
///
/// It is still a turn in progress -- nothing settles, nothing is invited
/// -- and it is not a measurement of the prompt: it says the request is
/// out and the model has said nothing yet, which for a server serving one
/// slot is what reading the prompt looks like.
Reading,
/// The session's own turn is over, but work it started is still going:
/// a backgrounded subagent, or a command left running.
///
/// Its own state rather than `Idle` because the two differ in kind and
/// only one of them is an invitation. `Idle` means the session is
/// waiting for a person; this means it is waiting for itself, and a
/// notification saying the work had finished would have been wrong. It
/// is also not `Running`: nothing is being written to the transcript,
/// the reply that ended the turn is finished, and a spinner on a session
/// that will not speak again until a task reports back is a promise
/// nobody can keep.
Waiting,
Exited, Exited,
/// There is a process recorded for this session and the machine will not /// There is a process recorded for this session and the machine will not
/// say whether it is still running. /// say whether it is still running.
@@ -422,13 +728,54 @@ pub enum Unqueued {
/// is the backpressure-free buffer of record. /// is the backpressure-free buffer of record.
pub type EventSink = mpsc::UnboundedSender<Event>; pub type EventSink = mpsc::UnboundedSender<Event>;
/// One piece of work a session has running while it is free to do something
/// else: a backgrounded command, a subagent, whatever else a provider can
/// leave going.
///
/// Runtime state, never written to a transcript: it is what the provider
/// says right now, so a session with no process has nothing to say. Served
/// by `GET /sessions/{id}/background`; the `BackgroundTasks` event carries
/// only the size, which is what the status row draws.
///
/// [`description`](Self::description) is `None` where the provider names a
/// task by something no reader would recognise -- a process id -- rather
/// than by a sentence worked out here; the phone says it does not know.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BackgroundTask {
/// The provider's own id for it. Never shown; it is what makes two
/// snapshots comparable, and what keys the list on the phone.
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub kind: BackgroundTaskKind,
}
/// What kind of thing a [`BackgroundTask`] is, in the terms the app draws.
/// `Other` is deliberately a state of its own rather than a guess: a
/// provider word this build has not seen is not a command.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum BackgroundTaskKind {
Agent,
Command,
Workflow,
Other,
}
/// The inbound half of a session. Deliberately small; see PLAN.md for the /// The inbound half of a session. Deliberately small; see PLAN.md for the
/// per-driver mapping of each method onto its dialect. /// per-driver mapping of each method onto its dialect.
/// ///
/// `send_user_message` during a run is the point of the whole app: both /// `send_user_message` during a run is the point of the whole app: a dialect
/// real dialects queue it for injection at the next tool boundary rather /// with live input injects it at the next tool boundary, while a turn-at-a-time
/// than the end of the turn. /// dialect queues it for the next child process.
pub trait Driver: Send + Sync { pub trait Driver: Send + Sync {
/// The background work the provider says is alive now, in the order it
/// wants it read. `None` means it has not reported, not that there is
/// none -- see [`BackgroundTask`].
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
None
}
/// Takes a message, now or once the session is free for it. /// Takes a message, now or once the session is free for it.
/// ///
/// Every driver owes exactly one `MessageTaken` per message, at the moment /// Every driver owes exactly one `MessageTaken` per message, at the moment
@@ -474,6 +821,16 @@ pub trait Driver: Send + Sync {
/// `/rename` afterwards, which is what puts the same name in its own /// `/rename` afterwards, which is what puts the same name in its own
/// session picker and in what other agents see. /// session picker and in what other agents see.
fn set_title(&self, title: &str); fn set_title(&self, title: &str);
/// Takes the session's provider settings, whole.
///
/// The whole map because it is a form's contents -- an absent key means
/// "unset", not "unchanged". A driver applies what it can apply now and
/// says so about the rest: the map is also on disk by the time this is
/// called, so a setting that only takes effect at the next start is not
/// lost, it is waiting. The default is right for a driver with no settings
/// of its own, which is every one but llama.cpp -- see
/// [`crate::config::DriverKind::params`].
fn set_params(&self, _params: &std::collections::BTreeMap<String, String>) {}
/// Runs a command this session's own dialect understands, verbatim -- /// Runs a command this session's own dialect understands, verbatim --
/// `/context`, `/usage`, anything a CLI adds next month. A driver with no /// `/context`, `/usage`, anything a CLI adds next month. A driver with no
/// such vocabulary says so with an [`Event::Error`] rather than sending it /// such vocabulary says so with an [`Event::Error`] rather than sending it
@@ -538,6 +895,23 @@ pub trait Driver: Send + Sync {
mod tests { mod tests {
use super::*; use super::*;
/// A steer says so to the model and nowhere else: the note goes only into
/// what the CLI is handed, while the transcript keeps what was typed.
#[test]
fn only_a_steer_carries_the_note() {
let files = [std::path::PathBuf::from("/tmp/x/trace.txt")];
let plain = message_body("do the thing", &files, false);
assert_eq!(plain, "do the thing\n\nAttached file: /tmp/x/trace.txt");
let steer = message_body("do the thing", &files, true);
assert_eq!(steer, format!("{STEERING_NOTE}\n\n{plain}"));
// Attachments with nothing typed still need the note, and must not
// arrive with a blank line where the words would have been.
assert_eq!(
message_body("", &files, true),
format!("{STEERING_NOTE}\n\nAttached file: /tmp/x/trace.txt")
);
}
/// A tripwire for the wire format, not for serde. The app reads these /// A tripwire for the wire format, not for serde. The app reads these
/// names, and getting one wrong does not fail loudly: a field the app /// names, and getting one wrong does not fail loudly: a field the app
/// cannot find reads as a field the server chose not to send, which /// cannot find reads as a field the server chose not to send, which
@@ -575,6 +949,8 @@ mod tests {
Event::UsageDelta { Event::UsageDelta {
tokens: 12, tokens: 12,
context: Some(30_100), context: Some(30_100),
tokens_per_second: None,
prefill_ms: None,
} }
), ),
Some(30_100) Some(30_100)
@@ -614,6 +990,8 @@ mod tests {
Event::UsageDelta { Event::UsageDelta {
tokens: 12, tokens: 12,
context: None, context: None,
tokens_per_second: None,
prefill_ms: None,
} }
), ),
Some(30_100) Some(30_100)
+414 -10
View File
@@ -10,6 +10,8 @@
//! - `/tool [input]` -- a full tool run, start through end. //! - `/tool [input]` -- a full tool run, start through end.
//! - `/bash [command]` -- a Bash call carrying that command, for what the //! - `/bash [command]` -- a Bash call carrying that command, for what the
//! phone's shell highlighting does to a particular line. //! phone's shell highlighting does to a particular line.
//! - `/patch` -- one common patch call, for the diff presentation shared by
//! real Codex and Claude sessions.
//! - `/tools [n] [gap]` -- n calls back to back. `gap` is seconds between one //! - `/tools [n] [gap]` -- n calls back to back. `gap` is seconds between one
//! call and the next, which is what makes a run *grow* while somebody is //! call and the next, which is what makes a run *grow* while somebody is
//! looking at it -- the only way to reach the state where a call opened on //! looking at it -- the only way to reach the state where a call opened on
@@ -26,6 +28,20 @@
//! - `/error [text]` -- a failure, which is otherwise awkward to cause. //! - `/error [text]` -- a failure, which is otherwise awkward to cause.
//! - `/peer [text]`, `/peer-turn` -- a message from another agent, in the //! - `/peer [text]`, `/peer-turn` -- a message from another agent, in the
//! in-place and the live shapes. //! in-place and the live shapes.
//! - `/usage [what]` -- an invented rate-limit answer, or `/usage off` to take
//! it away. An echo session meters nothing, so it draws no usage bar until
//! this is set; what it exists for is the states that bar can be in, which
//! otherwise cost real quota to reach. `/usage 42`, `/usage 95 20`,
//! `/usage 42 never`, `/usage notloggedin`, `/usage unreachable`,
//! `/usage failed`. The vocabulary is `usage::Fixture`'s, where the states
//! live.
//! - `/limit [minutes]` -- a turn that stops because the account is out of
//! quota, saying the limit lifts in `minutes` (default 5, and `never` for a
//! limit with no stated reset). What it exists for is auto-resume, which is
//! otherwise reachable only by actually exhausting somebody's account: pair
//! it with `/usage 100 5` for a meter that agrees, and then `/usage 20` for
//! the moment the limit lifts. The wait itself is decided by the meter, so
//! those two commands are the whole rig.
//! - `/compact` -- a compaction, start to finish. //! - `/compact` -- a compaction, start to finish.
//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the shape a //! - `/stream N` -- one long answer in N small pieces, 50ms apart: the shape a
//! real model's reply arrives in, and the one where the row a reader is //! real model's reply arrives in, and the one where the row a reader is
@@ -34,6 +50,19 @@
//! and height the app draws, in one session, which is what a scrolling //! and height the app draws, in one session, which is what a scrolling
//! problem needs in order to be reproduced twice the same way. //! problem needs in order to be reproduced twice the same way.
//! - `/table [columns]` -- a markdown table with cells too long for one line. //! - `/table [columns]` -- a markdown table with cells too long for one line.
//! - `/subagent [n]` -- n subagents at once (default 1), each named
//! "helper k", its prompt recorded as its own first user message: a
//! streamed reply, one Bash call, then it finishes about three seconds
//! later, the same lifecycle a real Task call has -- see `SUBAGENTS.md`.
//! The parent's own turn ends in `waiting` rather than `idle` while they
//! run, each records its closing report in its own transcript, and the
//! parent then runs a turn answering it -- which is the whole of the shape a
//! real background Task produces, and the one where two replies used to be
//! drawn as one paragraph.
//! - `/background [seconds]` -- a backgrounded *command*: the same shape with
//! no subagent behind it, so its report has nowhere to go but the card that
//! launched it. Until then that card says the command is running, which is
//! the stale claim this exists to watch being corrected.
//! //!
//! `/slow` earns its place: a queued message, a Stop button and a spinner are //! `/slow` earns its place: a queued message, a Stop button and a spinner are
//! states that only exist mid-turn, and the obvious way to get one -- ask a //! states that only exist mid-turn, and the obvious way to get one -- ask a
@@ -46,8 +75,10 @@ use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use super::driver::{ use super::driver::{
AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued, AttachmentRef, BackgroundTask, BackgroundTaskKind, Driver, Event, EventSink, QuestionOption,
SessionStatus, Unqueued, patch_start,
}; };
use super::subagent::Subagents;
/// Delay between streamed deltas -- long enough that streaming is visibly /// Delay between streamed deltas -- long enough that streaming is visibly
/// streaming, short enough that tests waiting on a full turn stay fast. /// streaming, short enough that tests waiting on a full turn stay fast.
@@ -85,11 +116,22 @@ pub struct EchoDriver {
/// because `/ask` puts up to four on one tool call, and the turn resumes /// because `/ask` puts up to four on one tool call, and the turn resumes
/// when the last is answered rather than the first. /// when the last is answered rather than the first.
pending_questions: Mutex<Vec<PendingQuestion>>, pending_questions: Mutex<Vec<PendingQuestion>>,
/// The invented rate-limit answer `/usage` sets, shared with the usage
/// monitor that serves it. An echo session meters nothing, so this is unset
/// until a test asks for something -- see [`crate::usage::Fixture`].
usage: crate::usage::Fixture,
/// A pretend context, so the status row has something that behaves the way /// A pretend context, so the status row has something that behaves the way
/// a real one does: it grows with each turn, drops to what the compaction /// a real one does: it grows with each turn, drops to what the compaction
/// says it recovered, and a clear leaves it unmeasured. What is real is /// says it recovered, and a clear leaves it unmeasured. What is real is
/// which way the numbers move. /// which way the numbers move.
context: Arc<AtomicU64>, context: Arc<AtomicU64>,
/// Live background commands, for the same list a real provider reports.
/// This is the deterministic UI/session-lifecycle rig for that state.
background_tasks: Arc<Mutex<Vec<BackgroundTask>>>,
/// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
/// test rig for the same registry the claude driver routes real Task
/// calls into.
subagents: Arc<Subagents>,
} }
impl EchoDriver { impl EchoDriver {
@@ -311,6 +353,212 @@ impl EchoDriver {
return; return;
} }
// Answered here rather than in the turn below, because it is not a
// turn: nothing is generated, and what is being exercised is the
// *other* screens -- the bar under the header, the button beside it and
// the dialog it opens, which read the usage route, not this transcript.
if let Some(rest) = text.strip_prefix("/usage") {
if announce {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
attachments,
});
}
let said = self.usage.command(rest);
self.emit(Event::AssistantText {
delta: format!("{said}\n"),
});
self.emit(Event::Status {
state: SessionStatus::Idle,
});
return;
}
// A turn that ends the way a real one does when the account runs out:
// the same event a real driver reports, so what acts on it -- the
// transcript row and `crate::resume` -- is exercised rather than
// imitated. The meter it should agree with is `/usage`'s fixture,
// deliberately separate: the two disagreeing is a state worth being
// able to produce, since it is what a stale reset time looks like.
if let Some(rest) = text.strip_prefix("/limit") {
if announce {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
attachments,
});
}
let rest = rest.trim();
let resets_at = match rest {
"never" | "none" => None,
"" => Some(super::now() + 5.0 * 60.0),
minutes => Some(super::now() + minutes.parse::<f64>().unwrap_or(5.0) * 60.0),
};
self.emit(Event::Status {
state: SessionStatus::Running,
});
self.emit(Event::AssistantText {
delta: "Working on it".to_string(),
});
self.emit(Event::LimitReached { resets_at });
self.emit(Event::Status {
state: SessionStatus::Idle,
});
return;
}
// `n` subagents at once, each with its own transcript in the
// registry a real Task call routes into -- see `SUBAGENTS.md`. The
// parent's own Task calls end when their subagent does, three
// seconds later, which is long enough to see the running state on
// the phone before it finishes.
if let Some(rest) = text.strip_prefix("/subagent") {
let n = rest.trim().parse::<usize>().unwrap_or(1).clamp(1, 8);
if announce {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
attachments,
});
}
self.emit(Event::Status {
state: SessionStatus::Running,
});
let sink = self.sink.clone();
let subagents = Arc::clone(&self.subagents);
tokio::spawn(async move {
let mut helpers = Vec::new();
for k in 1..=n {
let id = format!("echo-subagent-{k}-{}", super::random_hex());
let title = format!("helper {k}");
let prompt = format!(
"You are helper {k} of {n}. Say a few words, run a command, then stop."
);
let _ = sink.send(Event::ToolStart {
id: id.clone(),
tool: "Task".to_string(),
input: serde_json::json!({
"description": title,
"prompt": prompt,
"subagent_type": "general-purpose",
}),
});
subagents.start(&id, &title, Some(&prompt));
helpers.push((k, id, title, sink.clone(), Arc::clone(&subagents)));
}
// How many are still to report, so the last one to finish
// is the one that puts the session back to idle -- see
// `SessionStatus::Waiting`.
let outstanding = Arc::new(AtomicU64::new(helpers.len() as u64));
for (k, id, title, sink, subagents) in helpers {
tokio::spawn(run_helper(
k,
id,
title,
sink,
subagents,
Arc::clone(&outstanding),
));
}
// Not idle: the session's turn is over but its helpers are
// still going, and it will speak again with nobody having
// typed anything.
let _ = sink.send(Event::Status {
state: SessionStatus::Waiting,
});
});
return;
}
// A backgrounded command: the task shape with no subagent behind it.
// Its own verb because it is the *other* half of what a task
// notification does -- a subagent's report goes into the subagent's
// transcript, and this one has nowhere to go but the card that
// launched it, which until then is still saying the command is
// running.
if let Some(rest) = text.strip_prefix("/background") {
let seconds = rest.trim().parse::<u64>().unwrap_or(4).clamp(1, 120);
if announce {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
attachments,
});
}
self.emit(Event::Status {
state: SessionStatus::Running,
});
let id = format!("echo-background-{}", super::random_hex());
let command = format!("sleep {seconds} && echo done");
self.emit(Event::ToolStart {
id: id.clone(),
tool: "Bash".to_string(),
input: serde_json::json!({
"command": command,
"description": "wait a moment",
"run_in_background": true,
}),
});
self.emit(Event::ToolEnd {
id: id.clone(),
output: format!("Command running in background with ID: {id}"),
});
let background_tasks = Arc::clone(&self.background_tasks);
{
let mut tasks = background_tasks.lock().unwrap();
tasks.push(BackgroundTask {
id: id.clone(),
description: Some(command.clone()),
kind: BackgroundTaskKind::Command,
});
self.emit(Event::BackgroundTasks { count: tasks.len() });
}
for word in "Started it; I'll pick this up when it lands.".split_inclusive(' ') {
self.emit(Event::AssistantText {
delta: word.to_string(),
});
}
// Not idle: the command is still going, and the session will speak
// again with nobody having typed anything.
self.emit(Event::Status {
state: SessionStatus::Waiting,
});
let sink = self.sink.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(seconds)).await;
{
let mut tasks = background_tasks.lock().unwrap();
tasks.retain(|task| task.id != id);
let _ = sink.send(Event::BackgroundTasks { count: tasks.len() });
}
let _ = sink.send(Event::ToolUpdate {
id,
output: format!(r#"Background command "{command}" completed (exit code 0)"#),
});
let _ = sink.send(Event::Status {
state: SessionStatus::Running,
});
for word in "Done -- it finished cleanly.".split_inclusive(' ') {
let _ = sink.send(Event::AssistantText {
delta: word.to_string(),
});
tokio::time::sleep(DELTA_DELAY).await;
}
{
let tasks = background_tasks.lock().unwrap();
let _ = sink.send(Event::Status {
state: if tasks.is_empty() {
SessionStatus::Idle
} else {
SessionStatus::Waiting
},
});
}
});
return;
}
// The same word the real CLI takes, so a phone drives both the same way. // The same word the real CLI takes, so a phone drives both the same way.
// `Driver::compact` is what the manager's route calls; this is the typed // `Driver::compact` is what the manager's route calls; this is the typed
// path onto it. // path onto it.
@@ -379,10 +627,15 @@ impl EchoDriver {
.and_then(|w| w.parse().ok()) .and_then(|w| w.parse().ok())
.unwrap_or(3usize) .unwrap_or(3usize)
.clamp(2, 12); .clamp(2, 12);
// How long to wait between calls, default none. A run that arrives // How long each call spends running, default none. A run that
// all at once cannot exercise a run *growing*: the case worth // arrives all at once cannot exercise a run *growing*: the case
// watching is a call somebody has opened and is reading when the // worth watching is a call somebody has opened and is reading when
// next one turns it into a group. // the next one turns it into a group. Spent between the call's start
// and its end rather than between one call and the next, because
// that is where a real session's time goes -- and a call is drawn
// outside its group while it runs, which is a state nothing could
// see while every call here ended a few milliseconds after it
// started.
let gap = Duration::from_secs( let gap = Duration::from_secs(
words words
.next() .next()
@@ -401,6 +654,7 @@ impl EchoDriver {
let run_bash = text let run_bash = text
.strip_prefix("/bash") .strip_prefix("/bash")
.map(|rest| rest.trim().to_string()); .map(|rest| rest.trim().to_string());
let run_patch = text == "/patch";
// Seconds to stay running before answering, default 30. Clamped rather // Seconds to stay running before answering, default 30. Clamped rather
// than trusted: a session pinned running for an hour by a typo is a // than trusted: a session pinned running for an hour by a typo is a
// worse outcome than a short wait. // worse outcome than a short wait.
@@ -417,6 +671,12 @@ impl EchoDriver {
let table = text let table = text
.strip_prefix("/table") .strip_prefix("/table")
.map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12)); .map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12));
// Seconds to spend thinking before the reply, default three. The rig
// for the thinking card: a block that runs long enough to watch the
// spinner, then ends with a duration to read.
let think = text
.strip_prefix("/think")
.map(|rest| Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(3).clamp(1, 600)));
let linger = text.strip_prefix("/slow").map(|rest| { let linger = text.strip_prefix("/slow").map(|rest| {
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600)) Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
}); });
@@ -477,9 +737,6 @@ impl EchoDriver {
if let Some((count, gap)) = many_tools { if let Some((count, gap)) = many_tools {
for i in 1..=count { for i in 1..=count {
if i > 1 {
tokio::time::sleep(gap).await;
}
let id = format!("t-{}", super::random_hex()); let id = format!("t-{}", super::random_hex());
send(Event::ToolStart { send(Event::ToolStart {
id: id.clone(), id: id.clone(),
@@ -510,7 +767,7 @@ impl EchoDriver {
}); });
} }
} }
tokio::time::sleep(DELTA_DELAY).await; tokio::time::sleep(DELTA_DELAY + gap).await;
send(Event::ToolEnd { send(Event::ToolEnd {
id, id,
output: format!("call {i} finished"), output: format!("call {i} finished"),
@@ -570,6 +827,20 @@ impl EchoDriver {
}); });
} }
if run_patch {
let id = format!("p-{}", super::random_hex());
send(patch_start(
id.clone(),
"--- src/example.rs\n+++ src/example.rs\n@@ -1,3 +1,3 @@\n fn answer() -> u8 {\n- 41\n+ 42\n }\n\n--- /dev/null\n+++ notes.md\n+- added bullet\n+added prose\n\n--- old-notes.md\n+++ /dev/null\n-- removed bullet\n-removed prose\n"
.to_string(),
));
tokio::time::sleep(DELTA_DELAY).await;
send(Event::ToolEnd {
id,
output: String::new(),
});
}
if let Some(input) = run_tool { if let Some(input) = run_tool {
let id = format!("t-{}", super::random_hex()); let id = format!("t-{}", super::random_hex());
send(Event::ToolStart { send(Event::ToolStart {
@@ -589,10 +860,31 @@ impl EchoDriver {
}); });
} }
if let Some(think) = think {
let started = std::time::Instant::now();
for remaining in (1..=think.as_secs()).rev() {
send(Event::Thinking {
delta: format!(
"Considering what to echo back, {remaining}s of it left. \
The reply is the message, which took some working out.\n\n"
),
});
tokio::time::sleep(Duration::from_secs(1)).await;
}
// Measured here for the same reason a driver measures it: the
// phone can only see when an event arrived.
send(Event::ThinkingDone {
ms: started.elapsed().as_millis() as u64,
});
}
let streaming = std::time::Instant::now();
let mut words = 0u64;
for word in format!("You said: {text}").split_inclusive(' ') { for word in format!("You said: {text}").split_inclusive(' ') {
send(Event::AssistantText { send(Event::AssistantText {
delta: word.to_string(), delta: word.to_string(),
}); });
words += 1;
tokio::time::sleep(DELTA_DELAY).await; tokio::time::sleep(DELTA_DELAY).await;
} }
// A conversation gets bigger, so the pretend context does too: // A conversation gets bigger, so the pretend context does too:
@@ -601,19 +893,35 @@ impl EchoDriver {
send(Event::UsageDelta { send(Event::UsageDelta {
tokens: spent, tokens: spent,
context: Some(context.fetch_add(spent + 100, Ordering::SeqCst) + spent + 100), context: Some(context.fetch_add(spent + 100, Ordering::SeqCst) + spent + 100),
// A real measurement of a pretend model: what this driver
// emitted, over how long it took. A rig owes the app a figure
// of the shape a real one has, not an invented value.
tokens_per_second: Some(streaming.elapsed().as_secs_f64())
.filter(|elapsed| *elapsed > 0.0)
.map(|elapsed| words as f64 / elapsed),
// Nothing to read: this driver has no prompt to process.
prefill_ms: None,
}); });
finish(); finish();
}); });
} }
pub fn new(sink: EventSink, session_dir: PathBuf) -> Self { pub fn new(
sink: EventSink,
session_dir: PathBuf,
usage: crate::usage::Fixture,
subagents: Arc<Subagents>,
) -> Self {
let driver = Self { let driver = Self {
sink, sink,
pending_questions: Mutex::new(Vec::new()), pending_questions: Mutex::new(Vec::new()),
context: Arc::new(AtomicU64::new(0)), context: Arc::new(AtomicU64::new(0)),
background_tasks: Arc::new(Mutex::new(Vec::new())),
busy: Arc::new(AtomicBool::new(false)), busy: Arc::new(AtomicBool::new(false)),
queued: Arc::new(Mutex::new(Vec::new())), queued: Arc::new(Mutex::new(Vec::new())),
session_dir, session_dir,
usage,
subagents,
}; };
driver.emit(Event::Status { driver.emit(Event::Status {
state: SessionStatus::Idle, state: SessionStatus::Idle,
@@ -735,6 +1043,98 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
tokio::time::sleep(Duration::from_millis(120)).await; tokio::time::sleep(Duration::from_millis(120)).await;
} }
/// One `/subagent` helper: a few streamed words, one Bash call, then
/// `Status::Exited` about three seconds after it started -- long enough that
/// its `Running` state can be seen on the phone before it finishes. The
/// parent's own Task call for it ends at the same moment, the same way a
/// real Task's `tool_result` ends it.
/// One echo subagent's whole life, ending in the report its parent wakes up
/// for. `outstanding` is how many helpers are still to report; the one that
/// takes it to zero is the one that says the session is idle again, and `k` is
/// which helper this is, which is what staggers them.
async fn run_helper(
k: usize,
id: String,
title: String,
sink: EventSink,
subagents: Arc<Subagents>,
outstanding: Arc<AtomicU64>,
) {
let start = tokio::time::Instant::now();
for word in "Working on it now.".split_inclusive(' ') {
subagents.record(
&id,
Event::AssistantText {
delta: word.to_string(),
},
);
tokio::time::sleep(DELTA_DELAY).await;
}
let tool_id = format!("{id}-bash");
subagents.record(
&id,
Event::ToolStart {
id: tool_id.clone(),
tool: "Bash".to_string(),
input: serde_json::json!({ "command": "echo helper done" }),
},
);
tokio::time::sleep(DELTA_DELAY).await;
subagents.record(
&id,
Event::ToolEnd {
id: tool_id,
output: "helper done".to_string(),
},
);
// Staggered, one second apart: two helpers reporting at the same instant
// interleave the parent's replies word by word, which is a fixture
// artefact -- a real CLI runs one turn at a time -- and it hides the very
// thing this is a fixture for.
let target = Duration::from_secs(2 + k as u64);
let elapsed = start.elapsed();
if elapsed < target {
tokio::time::sleep(target - elapsed).await;
}
// The subagent's closing report, in the subagent's own transcript, which
// is where a real one's goes and the only place it belongs -- recorded
// before the ending, so it is not below it.
let summary = format!("{title} finished and had nothing to report.");
subagents.record(
&id,
Event::AssistantText {
delta: summary.clone(),
},
);
subagents.finish(&id);
let _ = sink.send(Event::ToolEnd {
id,
output: "subagent finished".to_string(),
});
// And then the turn the session runs because the task reported back. The
// parent has to say something afterwards, since the defect this
// reproduces is two replies meeting with nothing between them -- and
// nothing at all about the helper goes into the *parent's* transcript,
// which is the shape being reproduced.
let _ = sink.send(Event::Status {
state: SessionStatus::Running,
});
for word in format!("Noted, {title} is done.").split_inclusive(' ') {
let _ = sink.send(Event::AssistantText {
delta: word.to_string(),
});
tokio::time::sleep(DELTA_DELAY).await;
}
let last = outstanding.fetch_sub(1, Ordering::SeqCst) <= 1;
let _ = sink.send(Event::Status {
state: if last {
SessionStatus::Idle
} else {
SessionStatus::Waiting
},
});
}
/// A message written during a turn and waiting for it to end: the id of the /// A message written during a turn and waiting for it to end: the id of the
/// `MessageQueued` that announced it, what it said, and what was attached. All /// `MessageQueued` that announced it, what it said, and what was attached. All
/// three, because all three are what the `MessageTaken` at the other end owes. /// three, because all three are what the `MessageTaken` at the other end owes.
@@ -827,6 +1227,10 @@ fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
} }
impl Driver for EchoDriver { impl Driver for EchoDriver {
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
Some(self.background_tasks.lock().unwrap().clone())
}
fn between_turns(&self) -> bool { fn between_turns(&self) -> bool {
!self.busy.load(Ordering::SeqCst) !self.busy.load(Ordering::SeqCst)
} }
+1 -1
View File
@@ -13,7 +13,7 @@
//! //!
//! **The phone never names a file.** It picks an id out of what this //! **The phone never names a file.** It picks an id out of what this
//! module enumerated, and the path is looked up again on the server -- the //! module enumerated, and the path is looked up again on the server -- the
//! same rule the setups model follows for providers, and for the same //! same rule the machines model follows for providers, and for the same
//! reason: an enrolled token must not be able to turn into "read me this //! reason: an enrolled token must not be able to turn into "read me this
//! arbitrary path". //! arbitrary path".
-788
View File
@@ -1,788 +0,0 @@
//! The llama.cpp driver: a `llama-server` process per session, spoken to over
//! its OpenAI-compatible HTTP API and translated into the common event model.
//!
//! Two things make this shaped differently from the Claude driver.
//!
//! **It is spawned but not spoken to over stdio.** The process is started
//! through the same [`Transport`] as any other and then reached over HTTP on a
//! loopback port. A remote llama-server would need its port forwarded as well
//! as its command wrapped, which is not built, so a session on an ssh host is
//! refused rather than silently talking to the wrong machine.
//!
//! **The server is stateless between requests**, so the whole conversation goes
//! with every one. It is rebuilt from the session's transcript rather than kept
//! in this struct, which is not tidiness: a copy in driver memory is invisible
//! to a second device and gone when this process restarts.
//!
//! That leaves the Claude driver as the odd one out rather than this one -- the
//! CLI's own memory of a conversation is a cache in front of the same
//! transcript. Resolve any inconsistency in this direction.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::json;
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus};
use super::process;
use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig};
/// How long to wait for a model to load before giving up. Loading is mostly
/// disk, and a large quantised model on a cold cache is genuinely slow, so this
/// is generous -- the failure it exists for is a server that will never answer.
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// One turn in the conversation this driver keeps on the server's behalf.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Message {
role: String,
content: String,
}
pub struct LlamaDriver {
sink: EventSink,
/// Where this session's own llama-server answers.
endpoint: String,
/// Where the conversation is read back from, one line per event.
transcript: PathBuf,
/// Sampling settings chosen at spawn, sent with every request.
sampling: serde_json::Map<String, serde_json::Value>,
/// Set by [`Driver::interrupt`]; the streaming loop checks it between
/// chunks and stops, leaving what was generated in the transcript.
cancel: Arc<AtomicBool>,
/// Where this session's process record lives, so [`Driver::stop`] can find
/// the server it has to end.
session_dir: PathBuf,
}
impl LlamaDriver {
/// Takes charge of this session's `llama-server`: the one already loaded if
/// there is one, otherwise a new one.
///
/// One entry point, for the reason `ClaudeDriver::launch` gives, expensive
/// in a different currency: two servers holding the same model is twice the
/// memory, and the second would bind a different port while the phone kept
/// talking to the first.
pub fn launch(
meta: &SessionConfig,
provider: &ProviderConfig,
transport: &Transport,
models_dir: &Path,
transcript: &Path,
session_dir: &Path,
sink: EventSink,
) -> Result<Self> {
if !matches!(transport, Transport::Here) {
bail!(
"llama.cpp sessions can only run on this machine for now: the model is served \
over HTTP, and forwarding that port to another host isn't built yet."
);
}
let model = meta.model.as_deref().context(
"a llama.cpp session needs a model -- one of the downloaded ones, by its key",
)?;
let path = model_path(models_dir, model)?;
// Already loaded and still running: keep talking to it. The health poll
// below confirms it is really answering, so adopting a pid whose server
// has wedged still reports as a failure rather than as a session that
// silently never replies.
if let Some(process::Record {
detail: process::Detail::Http { port },
pid,
..
}) = process::live(session_dir)
{
tracing::info!(
"session {} reattaching to the llama-server it left loaded (pid {pid}, port {port})",
meta.id
);
return Ok(Self::attached(
format!("http://127.0.0.1:{port}"),
meta,
model,
transcript,
session_dir,
sink,
));
}
let port = free_port().context("finding a port for llama-server")?;
let mut args: Vec<String> = vec![
"-m".into(),
path.to_string_lossy().into_owned(),
"--host".into(),
"127.0.0.1".into(),
"--port".into(),
port.to_string(),
];
// Settings that belong to the server because they decide how the model
// is loaded; the sampling ones ride on each request instead, so changing
// them later needn't reload anything.
for (key, flag) in [
("contextSize", "-c"),
("gpuLayers", "-ngl"),
("threads", "-t"),
] {
if let Some(value) = meta.params.get(key) {
args.push(flag.to_string());
args.push(value.clone());
}
}
let program = provider.command.as_deref().unwrap_or("llama-server");
let launch = Launch::new(program, args, meta.cwd.as_deref());
// Its output goes to files, not pipes. Not only so the process can
// outlive this server: nothing ever read those pipes, so a chatty
// llama-server filled the 64 KB buffer and blocked mid-load with no sign
// of why.
let child = transport.spawn(
&launch,
Streams::Detached {
stdin: std::process::Stdio::null(),
stdout: log_file(&session_dir.join(SERVER_LOG))?.into(),
stderr: log_file(&session_dir.join(SERVER_LOG))?.into(),
},
)?;
let pid = child
.id()
.context("llama-server exited before it could be recorded")?;
tracing::info!(
"session {} running {program} for {model} on 127.0.0.1:{port} as pid {pid}",
meta.id
);
// Reaped so it does not become a zombie while this server is still its
// parent; the health poll and the record are what say whether the
// session is alive, because after a restart there is no `Child` to ask.
tokio::spawn(async move {
let mut child = child;
let _ = child.wait().await;
});
let record = process::Record::of(pid, process::Detail::Http { port })
.context("llama-server was gone before its start time could be read")?;
process::write(session_dir, &record);
Ok(Self::attached(
format!("http://127.0.0.1:{port}"),
meta,
model,
transcript,
session_dir,
sink,
))
}
/// The driver for a `llama-server` at `endpoint`, however it got there.
///
/// Shared by starting one and adopting one, because everything after "there
/// is a server at this address" is identical -- including waiting for it to
/// answer, which an adopted one still owes: a recorded pid says a process
/// exists, not that its model is loaded.
fn attached(
endpoint: String,
meta: &SessionConfig,
model: &str,
transcript: &Path,
session_dir: &Path,
sink: EventSink,
) -> Self {
// Loading is slow enough to be worth saying so: the session shows as
// running until the model is in memory, rather than looking ready and
// refusing the first message.
let _ = sink.send(Event::Status {
state: SessionStatus::Running,
});
{
let sink = sink.clone();
let endpoint = endpoint.clone();
let model = model.to_string();
let session_dir = session_dir.to_path_buf();
std::thread::spawn(move || match wait_until_ready(&endpoint) {
Ok(()) => {
tracing::info!("{model} loaded and answering at {endpoint}");
let _ = sink.send(Event::Status {
state: SessionStatus::Idle,
});
watch(session_dir, sink);
}
Err(err) => {
let _ = sink.send(Event::Error {
message: format!("{model} never became ready: {err:#}"),
});
let _ = sink.send(Event::Status {
state: SessionStatus::Exited,
});
process::clear(&session_dir);
}
});
}
let mut sampling = serde_json::Map::new();
for (key, field) in [
("temperature", "temperature"),
("topP", "top_p"),
("topK", "top_k"),
("maxTokens", "max_tokens"),
] {
if let Some(raw) = meta.params.get(key)
&& let Ok(number) = raw.parse::<f64>()
{
sampling.insert(field.to_string(), json!(number));
}
}
Self {
sink,
endpoint,
transcript: transcript.to_path_buf(),
sampling,
cancel: Arc::new(AtomicBool::new(false)),
session_dir: session_dir.to_path_buf(),
}
}
}
/// Where llama-server's own output goes. One file for both streams: it is
/// diagnostics nobody parses, and interleaving them is how it reads in a
/// terminal anyway.
const SERVER_LOG: &str = "llama-server.log";
/// How often a loaded server is checked for still being there. Slower than the
/// Claude driver's stdout poll because nothing is waiting on it: this only has
/// to notice a server that has gone.
const WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
/// An owner-only log opened for appending, so the two streams pointed at
/// it do not overwrite each other and a reattach keeps what came before.
fn log_file(path: &Path) -> Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(path)
.with_context(|| format!("opening {}", path.display()))
}
/// Reports the server going away, for as long as the session is there to report
/// it to.
///
/// Polled rather than waited on, for the reason the Claude driver gives: after a
/// restart this server is not the process's parent, so liveness has to be a
/// question asked of the record -- and asking it two different ways is how the
/// two answers come to disagree.
fn watch(session_dir: PathBuf, sink: EventSink) {
std::thread::spawn(move || {
loop {
std::thread::sleep(WATCH_INTERVAL);
match process::recorded(&session_dir) {
Some((_, process::Liveness::Alive)) => {}
// Nothing recorded means the session was stopped or deleted
// deliberately, and whoever did that has already said so.
None => return,
Some((_, process::Liveness::Dead)) => {
let _ = sink.send(Event::Error {
message: "llama-server exited".to_string(),
});
let _ = sink.send(Event::Status {
state: SessionStatus::Exited,
});
process::clear(&session_dir);
return;
}
Some((_, process::Liveness::Unknown)) => {
let _ = sink.send(Event::Status {
state: SessionStatus::Unknown,
});
}
}
if sink.is_closed() {
return;
}
}
});
}
impl Driver for LlamaDriver {
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
if !attachments.is_empty() {
let _ = self.sink.send(Event::Error {
message: "this model can't be sent attachments or files".to_string(),
});
}
let sink = self.sink.clone();
let endpoint = self.endpoint.clone();
let transcript = self.transcript.clone();
let sampling = self.sampling.clone();
let cancel = Arc::clone(&self.cancel);
cancel.store(false, Ordering::Relaxed);
// Its own thread: the request blocks for as long as the model takes to
// generate, which is the whole point of streaming it.
std::thread::spawn(move || {
// Nothing is ever held back here -- there is no queue to wait in --
// so the message is taken the moment it arrives. Said anyway,
// because this is what records it: see `MessageTaken`.
let _ = sink.send(Event::MessageTaken {
id: None,
text: text.clone(),
// Never any: this driver refuses attachments above.
attachments: Vec::new(),
});
let _ = sink.send(Event::Status {
state: SessionStatus::Running,
});
// Everything before this message, plus this message. Read rather
// than remembered, and `text` is appended here rather than waited
// for, because the message's own transcript entry is still on its
// way when this runs.
let mut messages = conversation(&transcript);
messages.push(Message {
role: "user".into(),
content: text,
});
// The reply is not stored: the deltas below are the durable record,
// so the next turn reads back exactly what the phone was shown --
// including a partial one that was interrupted.
if let Err(err) = generate(&endpoint, &messages, &sampling, &cancel, &sink) {
let _ = sink.send(Event::Error {
message: format!("{err:#}"),
});
}
let _ = sink.send(Event::Status {
state: SessionStatus::Idle,
});
});
}
fn answer_question(&self, _id: &str, _answers: &[String]) {
// Nothing here asks questions: this driver has no tools.
}
fn interrupt(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
// Nothing to forward: this process has no notion of what the conversation
// is called, and the rename has already happened where the name lives.
fn set_title(&self, _title: &str) {}
fn set_permission_mode(&self, _mode: &str) {
let _ = self.sink.send(Event::Error {
message: "a llama.cpp session runs no tools, so there is nothing for a permission \
mode to govern."
.to_string(),
});
}
fn set_model(&self, _model: &str) {
let _ = self.sink.send(Event::Error {
message: "a llama.cpp session's model is fixed when it starts, because the server \
loads one model into memory. Spawn another session to use a different one."
.to_string(),
});
}
fn run_command(&self, text: &str) {
let _ = self.sink.send(Event::Error {
message: format!(
"a llama.cpp session has no commands of its own, so {text} means nothing to it."
),
});
}
fn compact(&self) {
let _ = self.sink.send(Event::Error {
message: "llama.cpp has no compaction. Clear the session instead, which costs nothing."
.to_string(),
});
}
fn clear(&self) {
// All of it. `conversation` folds from the last of these, so recording
// the marker *is* the reset -- there is no driver state to keep in step
// with it, which is the same property that makes a second device see the
// same conversation this one does.
let _ = self.sink.send(Event::Cleared);
}
/// Stops generating and leaves the server loaded.
///
/// Worth being deliberate about, because the cost points the other way from
/// the Claude driver's: a `llama-server` holds its whole model in memory, so
/// a leaked one is gigabytes nobody is using. It is left anyway, because the
/// alternative is unloading and reloading that model on every backend
/// restart -- minutes of disk, for a session somebody is in the middle of.
/// The record is what keeps it from being *nobody's*.
fn detach(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
fn stop(&self) {
self.cancel.store(true, Ordering::Relaxed);
if let Some(record) = process::live(&self.session_dir) {
process::stop(&record, process::STOP_GRACE);
}
process::clear(&self.session_dir);
}
}
/// The conversation so far, folded out of the transcript.
///
/// Consecutive `AssistantText` deltas are one assistant turn, closed by the next
/// user message -- which is also what makes an interrupted reply come back as
/// the partial text the phone actually saw.
///
/// This must stay a pure function of the transcript and must never re-render
/// earlier turns. llama.cpp caches the prompt prefix, so a growing conversation
/// reprocesses almost nothing -- but only while every turn is byte-identical to
/// last time. Changing how an old turn is rendered silently reprocesses the
/// whole history on every message.
fn conversation(path: &Path) -> Vec<Message> {
let Ok(events) = crate::session::transcript::read_after(path, 0) else {
return Vec::new();
};
let mut messages: Vec<Message> = Vec::new();
let mut pending = String::new();
// Everything before the last clear is still in the transcript and is
// deliberately not in the conversation. Folding from zero would put it back,
// which is the whole of what clearing had to undo.
let events = match events.iter().rposition(|e| e.event == Event::Cleared) {
Some(at) => &events[at + 1..],
None => &events[..],
};
for event in events.iter().cloned() {
match event.event {
Event::UserMessage { text, .. } => {
if !pending.is_empty() {
messages.push(Message {
role: "assistant".into(),
content: std::mem::take(&mut pending),
});
}
messages.push(Message {
role: "user".into(),
content: text,
});
}
Event::AssistantText { delta } => pending.push_str(&delta),
_ => {}
}
}
if !pending.is_empty() {
messages.push(Message {
role: "assistant".into(),
content: pending,
});
}
messages
}
/// Where a model key resolves to on disk, refusing anything that climbs
/// out of the models directory -- the key arrives from a phone.
fn model_path(models_dir: &Path, key: &str) -> Result<PathBuf> {
let mut path = models_dir.to_path_buf();
for part in key.split('/') {
if part.is_empty() || part == "." || part == ".." {
bail!("\"{key}\" is not a model key this can resolve");
}
path.push(part);
}
if !path.is_file() {
bail!("no downloaded model called \"{key}\" -- download it first");
}
Ok(path)
}
/// An unused loopback port, by asking the OS for one and letting it go. Racy in
/// principle, but nothing on this machine is hunting for ports, and the
/// alternative -- parsing the port back out of the server's log -- couples us to
/// its output format for no real gain.
fn free_port() -> Result<u16> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
Ok(listener.local_addr()?.port())
}
/// Polls until the server says it is ready, or gives up.
fn wait_until_ready(endpoint: &str) -> Result<()> {
let deadline = std::time::Instant::now() + READY_TIMEOUT;
let url = format!("{endpoint}/health");
loop {
if let Ok(response) = ureq::get(&url).call()
&& response.status() == 200
{
return Ok(());
}
if std::time::Instant::now() > deadline {
bail!("gave up after {}s", READY_TIMEOUT.as_secs());
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
/// One streamed completion: posts the conversation, emits each delta as it
/// arrives. Emits rather than returns, because the transcript those events land
/// in is what the next turn reads back.
fn generate(
endpoint: &str,
messages: &[Message],
sampling: &serde_json::Map<String, serde_json::Value>,
cancel: &AtomicBool,
sink: &EventSink,
) -> Result<()> {
let mut body = json!({
"messages": messages,
"stream": true,
"stream_options": {"include_usage": true},
});
let map = body.as_object_mut().expect("built as an object");
for (key, value) in sampling {
map.insert(key.clone(), value.clone());
}
let mut response = ureq::post(format!("{endpoint}/v1/chat/completions"))
.header("Content-Type", "application/json")
.send_json(&body)
.context("asking llama-server to generate")?;
let reader = std::io::BufReader::new(response.body_mut().as_reader());
let mut tokens = 0u64;
// The prompt side only, which is what the model is holding -- the same
// definition the other dialects report, so one word on the phone means one
// thing whichever kind of session it is.
let mut context = None;
for line in std::io::BufRead::lines(reader) {
if cancel.load(Ordering::Relaxed) {
break;
}
let line = line.context("reading the generation stream")?;
// Server-sent events: the payload lines are the ones that matter.
let Some(payload) = line.strip_prefix("data: ") else {
continue;
};
if payload.trim() == "[DONE]" {
break;
}
let Ok(chunk) = serde_json::from_str::<serde_json::Value>(payload) else {
continue;
};
if let Some(usage) = chunk.get("usage") {
if let Some(total) = usage
.get("total_tokens")
.and_then(serde_json::Value::as_u64)
{
tokens = total;
}
if let Some(prompt) = usage
.get("prompt_tokens")
.and_then(serde_json::Value::as_u64)
{
context = Some(prompt);
}
}
let delta = chunk
.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("delta"))
.and_then(|d| d.get("content"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if !delta.is_empty() {
let _ = sink.send(Event::AssistantText {
delta: delta.to_string(),
});
}
}
if tokens > 0 {
let _ = sink.send(Event::UsageDelta { tokens, context });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::transcript::Transcript;
/// Writes a transcript the way the pump does, so the fold is tested against
/// the real file format rather than a hand-built vector.
fn transcript_with(events: &[Event]) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
let mut transcript = Transcript::open(&path).expect("open");
for event in events {
transcript.append(event.clone(), 0.0).expect("append");
}
(dir, path)
}
#[test]
fn deltas_between_user_messages_are_one_assistant_turn() {
let (_dir, path) = transcript_with(&[
Event::UserMessage {
id: None,
text: "hello".into(),
attachments: Vec::new(),
},
Event::AssistantText {
delta: "hi ".into(),
},
Event::AssistantText {
delta: "there".into(),
},
Event::Status {
state: SessionStatus::Idle,
},
Event::UserMessage {
id: None,
text: "again".into(),
attachments: Vec::new(),
},
Event::AssistantText {
delta: "yes".into(),
},
]);
let messages = conversation(&path);
assert_eq!(
messages
.iter()
.map(|m| (m.role.as_str(), m.content.as_str()))
.collect::<Vec<_>>(),
[
("user", "hello"),
("assistant", "hi there"),
("user", "again"),
("assistant", "yes")
],
);
}
#[test]
/// The interrupted case, which decides what a resumed conversation is built
/// from: whatever the phone was shown. The deltas that arrived before the
/// stop are in the transcript, so they are in the prompt -- the model is
/// never told it said something the user did not see.
fn an_interrupted_reply_stays_in_the_conversation() {
let (_dir, path) = transcript_with(&[
Event::UserMessage {
id: None,
text: "count".into(),
attachments: Vec::new(),
},
Event::AssistantText {
delta: "one two".into(),
},
Event::Status {
state: SessionStatus::Idle,
},
]);
let messages = conversation(&path);
assert_eq!(messages.len(), 2);
assert_eq!(messages[1].content, "one two");
}
#[test]
/// Events this driver does not produce must not disturb the fold: a
/// transcript can carry errors and status changes from a session that
/// was, say, relaunched.
fn other_events_are_not_part_of_the_conversation() {
let (_dir, path) = transcript_with(&[
Event::Status {
state: SessionStatus::Running,
},
Event::UserMessage {
id: None,
text: "hello".into(),
attachments: Vec::new(),
},
Event::Error {
message: "something went wrong".into(),
},
Event::AssistantText {
delta: "still here".into(),
},
Event::UsageDelta {
tokens: 12,
context: Some(12),
},
]);
let messages = conversation(&path);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].content, "hello");
assert_eq!(messages[1].content, "still here");
}
#[test]
/// Clearing decides what the *model* is given, not just what the phone
/// draws. Everything above the marker stays in the transcript and none of it
/// is sent.
fn the_conversation_starts_after_the_last_clear() {
let (_dir, path) = transcript_with(&[
Event::UserMessage {
id: None,
text: "the long expensive conversation".into(),
attachments: Vec::new(),
},
Event::AssistantText {
delta: "at length".into(),
},
Event::Cleared,
Event::UserMessage {
id: None,
text: "a fresh start".into(),
attachments: Vec::new(),
},
Event::AssistantText {
delta: "cheaply".into(),
},
]);
let messages = conversation(&path);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].content, "a fresh start");
assert_eq!(messages[1].content, "cheaply");
}
#[test]
/// The *last* one, so clearing twice does not resurrect what the
/// first clear dropped.
fn only_the_newest_clear_counts() {
let (_dir, path) = transcript_with(&[
Event::UserMessage {
id: None,
text: "one".into(),
attachments: Vec::new(),
},
Event::Cleared,
Event::UserMessage {
id: None,
text: "two".into(),
attachments: Vec::new(),
},
Event::Cleared,
Event::UserMessage {
id: None,
text: "three".into(),
attachments: Vec::new(),
},
]);
let messages = conversation(&path);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].content, "three");
}
#[test]
fn a_model_key_cannot_climb_out_of_the_models_directory() {
let dir = tempfile::tempdir().expect("tempdir");
for attempt in ["../../etc/passwd", "unsloth/../../escape.gguf", ""] {
assert!(
model_path(dir.path(), attempt).is_err(),
"{attempt:?} should have been refused",
);
}
}
}
+375
View File
@@ -0,0 +1,375 @@
//! An MCP client, for the tools a llama session has that `llama-server` does
//! not provide itself.
//!
//! **Why this is here and not a flag on `llama-server`.** That server can host
//! MCP servers (`--mcp-servers-json`), but only ones it can *spawn*: its
//! configuration is Cursor's, and an entry without a `command` is skipped with
//! "MCP server 'exa' has no command". Exa's is a remote HTTP endpoint with
//! nothing to spawn, so reaching it that way means a local process bridging
//! stdio to HTTP -- a Node install on the machine serving the model, and a
//! package to keep current, for what is three JSON-RPC calls.
//!
//! llama.cpp's own web UI does not do that either. It ships Exa in a
//! "recommended servers" list and connects to `https://mcp.exa.ai/mcp`
//! *itself*, from the browser. This is the same arrangement with this server
//! in the browser's place, and it is the right one for a second reason: it
//! puts the search on the machine running the backend rather than on whichever
//! machine happens to be serving the model, which may have no route out at
//! all.
//!
//! **Only the three calls a tool needs.** `initialize`, `tools/list`,
//! `tools/call`. Nothing here implements resources, prompts, sampling or the
//! server-to-client stream, because nothing here uses them; a session's tools
//! are a list fetched once and a call made on demand. That is why this is a
//! file rather than a dependency on a protocol crate -- there is no spec
//! surface to get subtly wrong, only a request and its reply.
//!
//! Transport is "streamable HTTP": every message is a POST, and the reply is
//! either JSON or a one-event SSE stream carrying the same JSON. Both are
//! accepted because which one arrives is the server's choice, not ours.
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
/// Identifies this client to an MCP server.
///
/// Not politeness: Exa's endpoint is behind Cloudflare, which answers **403**
/// to a request with no `User-Agent` at all (measured 2026-09-19 -- the same
/// request with one succeeds). A client that omitted it would look exactly
/// like a server that was refusing us.
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
/// The protocol version this speaks. Sent at `initialize`; a server that
/// prefers another says so in its answer and this goes along with whatever it
/// then sends, since none of the three calls here has changed between
/// versions.
const PROTOCOL_VERSION: &str = "2025-06-18";
/// How long any one call may take.
///
/// Generous because a web search is a search: Exa fetches and cleans pages
/// before answering. Bounded at all because this blocks a turn, and a tool
/// that never returns is a session that never speaks again.
const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// A connected MCP server, and the tools it offered.
pub struct McpServer {
/// The name this server is configured under. It prefixes every tool, so
/// two servers offering `search` are two different tools.
name: String,
url: String,
/// What the server called this conversation, when it named one. Sent back
/// on every later request; a server that keeps no session sends no header
/// and this stays `None`.
session: Option<String>,
/// The tool names this server answers to, without the prefix, keyed by the
/// prefixed name the model is given.
tools: Vec<McpTool>,
}
/// One tool an MCP server offers, in both the names it has.
pub struct McpTool {
/// `{server}_{tool}` -- what the model calls it, and what comes back in a
/// tool call. Prefixed the way `llama-server` prefixes the MCP tools it
/// hosts itself, so a reader sees one naming convention whichever side a
/// tool came from.
pub qualified: String,
/// What the server calls it.
bare: String,
/// The OpenAI-shaped function definition sent to the model.
pub definition: Value,
}
impl McpServer {
/// Connects, handshakes, and asks what it can do.
///
/// All three steps or none: a server that answered `initialize` and then
/// failed to list its tools is not a server with no tools, and returning
/// an empty list for it would put a session on screen that silently
/// cannot search.
pub fn connect(name: &str, url: &str) -> Result<Self> {
let mut server = Self {
name: name.to_string(),
url: url.to_string(),
session: None,
tools: Vec::new(),
};
server
.request(
1,
"initialize",
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "ai-server", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")},
}),
)
.with_context(|| format!("handshaking with the {name} MCP server at {url}"))?;
// A notification: no id, and the server answers with no body. Sent
// because the specification requires it before any other call, and
// Exa's server does enforce it.
server.notify("notifications/initialized")?;
let listed = server
.request(2, "tools/list", json!({}))
.with_context(|| format!("asking the {name} MCP server what it offers"))?;
server.tools = listed
.get("tools")
.and_then(Value::as_array)
.map(|tools| {
tools
.iter()
.filter_map(|tool| server.describe(tool))
.collect()
})
.unwrap_or_default();
Ok(server)
}
/// Turns one entry of `tools/list` into the function definition a model is
/// given, or `None` for one this cannot name or call.
fn describe(&self, tool: &Value) -> Option<McpTool> {
let bare = tool.get("name").and_then(Value::as_str)?.to_string();
let qualified = format!("{}_{bare}", self.name);
let mut function = serde_json::Map::new();
function.insert("name".into(), json!(qualified));
if let Some(description) = tool.get("description").and_then(Value::as_str) {
function.insert("description".into(), json!(description));
}
// `inputSchema` in MCP, `parameters` in the OpenAI shape: the same
// JSON Schema under two names. A tool that declares none takes no
// arguments, which is an empty object rather than an absent key --
// some templates render the key unconditionally.
function.insert(
"parameters".into(),
tool.get("inputSchema")
.cloned()
.unwrap_or_else(|| json!({"type": "object", "properties": {}})),
);
Some(McpTool {
qualified,
bare,
definition: json!({"type": "function", "function": function}),
})
}
pub fn tools(&self) -> &[McpTool] {
&self.tools
}
/// Runs one of this server's tools, named as the model named it.
///
/// The result is the text a model is shown. A tool the server reports as
/// failing is **not** an error here: `isError` means the tool ran and went
/// wrong -- a search that found nothing, a page that would not fetch --
/// and the model is the one that has to know, so it comes back as its own
/// message. An error is reserved for not having reached the server at all.
pub fn call(&mut self, qualified: &str, arguments: &Value) -> Result<String> {
// The bare name is taken before the call, because the call needs the
// whole of `self` and the tool list is part of it.
let bare = self
.tools
.iter()
.find(|tool| tool.qualified == qualified)
.map(|tool| tool.bare.clone())
.with_context(|| format!("{} does not offer {qualified}", self.name))?;
let result = self.request(
3,
"tools/call",
json!({"name": bare, "arguments": arguments}),
)?;
Ok(rendered(&result))
}
/// One request, and its result.
///
/// `&self` rather than `&mut self` everywhere but the handshake would be
/// tidier and is wrong: the session header is assigned by the server on
/// the first reply and has to be kept.
fn request(&mut self, id: u64, method: &str, params: Value) -> Result<Value> {
let body = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
let answer = self.post(&body)?.with_context(|| {
format!(
"the {} MCP server answered {method} with nothing",
self.name
)
})?;
if let Some(message) = answer.pointer("/error/message").and_then(Value::as_str) {
bail!("{} refused {method}: {message}", self.name);
}
answer
.get("result")
.cloned()
.with_context(|| format!("the {} MCP server's {method} carried no result", self.name))
}
/// A message with no id, which is answered with no body.
fn notify(&mut self, method: &str) -> Result<()> {
self.post(&json!({"jsonrpc": "2.0", "method": method}))?;
Ok(())
}
/// Posts one JSON-RPC message and returns whatever came back, which for a
/// notification is nothing.
fn post(&mut self, body: &Value) -> Result<Option<Value>> {
let mut request = ureq::post(&self.url)
.config()
.timeout_global(Some(CALL_TIMEOUT))
.build()
.header("content-type", "application/json")
// Both, because which one a server replies with is its choice.
.header("accept", "application/json, text/event-stream")
.header("user-agent", USER_AGENT);
if let Some(session) = &self.session {
request = request.header("mcp-session-id", session);
}
let mut response = request
.send_json(body)
.with_context(|| format!("reaching the {} MCP server at {}", self.name, self.url))?;
if let Some(session) = response
.headers()
.get("mcp-session-id")
.and_then(|value| value.to_str().ok())
{
self.session = Some(session.to_string());
}
let streamed = response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("text/event-stream"));
let text = response
.body_mut()
.read_to_string()
.with_context(|| format!("reading the {} MCP server's answer", self.name))?;
Ok(first_message(&text, streamed))
}
}
/// The first JSON-RPC message in a reply body.
///
/// One, not all: every call here carries a single id and the server answers it
/// once. Server-sent events are unwrapped to their payload lines; a plain JSON
/// body is itself.
fn first_message(text: &str, streamed: bool) -> Option<Value> {
if streamed {
return text
.lines()
.filter_map(|line| line.strip_prefix("data: "))
.find_map(|payload| serde_json::from_str(payload).ok());
}
serde_json::from_str(text.trim()).ok()
}
/// A `tools/call` result as the text a model is given.
///
/// MCP answers with a list of content blocks; the text ones are joined and the
/// rest are named rather than dropped, because a model told nothing came back
/// will try again. `structuredContent` is used when there is no text at all,
/// which is how some servers answer entirely.
fn rendered(result: &Value) -> String {
let blocks = result.get("content").and_then(Value::as_array);
let mut parts: Vec<String> = Vec::new();
for block in blocks.into_iter().flatten() {
match block.get("type").and_then(Value::as_str) {
Some("text") => parts.push(
block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
),
Some(kind) => parts.push(format!("[{kind} content, which this session cannot show]")),
None => {}
}
}
if parts.iter().all(|part| part.trim().is_empty())
&& let Some(structured) = result.get("structuredContent")
{
return structured.to_string();
}
parts.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_event_stream_body_is_unwrapped_to_its_payload() {
let body =
"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n";
assert_eq!(
first_message(body, true),
Some(json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
);
}
#[test]
fn a_plain_json_body_is_the_message() {
let body = " {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n";
assert_eq!(
first_message(body, false),
Some(json!({"jsonrpc": "2.0", "id": 1, "result": {}})),
);
}
#[test]
/// A notification's reply, which is nothing at all.
fn an_empty_body_is_no_message() {
assert_eq!(first_message("", false), None);
assert_eq!(first_message("event: ping\n", true), None);
}
#[test]
fn text_blocks_are_joined_and_other_kinds_are_named() {
let result = json!({"content": [
{"type": "text", "text": "first"},
{"type": "image", "data": ""},
{"type": "text", "text": "second"},
]});
assert_eq!(
rendered(&result),
"first\n[image content, which this session cannot show]\nsecond",
);
}
#[test]
/// A server that answers only in structured form. Rendering "" for it
/// would tell the model the search came back empty, which is a different
/// fact from the one that is true.
fn a_result_with_no_text_falls_back_to_its_structured_form() {
let result = json!({"content": [], "structuredContent": {"hits": 2}});
assert_eq!(rendered(&result), "{\"hits\":2}");
}
#[test]
/// The real endpoint, which is the only thing that can confirm the
/// handshake, the session header and the SSE unwrapping all agree with a
/// server nobody here wrote. Skipped without network rather than failed:
/// `./run-tests.sh` has to pass on a machine with no route out.
fn exa_answers_a_search_over_the_real_protocol() {
let Ok(mut server) = McpServer::connect("exa", super::super::EXA_MCP_URL) else {
eprintln!("skipping: could not reach Exa");
return;
};
assert!(
server
.tools()
.iter()
.any(|tool| tool.qualified == "exa_web_search_exa"),
"Exa offered {:?}",
server
.tools()
.iter()
.map(|tool| &tool.qualified)
.collect::<Vec<_>>(),
);
let answer = server
.call(
"exa_web_search_exa",
&json!({"query": "llama.cpp server", "numResults": 1}),
)
.expect("search");
assert!(!answer.trim().is_empty(), "a search returned nothing");
}
}
File diff suppressed because it is too large. Load diff
+857
View File
@@ -0,0 +1,857 @@
//! One `llama-server` per machine, in **router mode**: the front door to every
//! model that machine serves, shared by every session on it.
//!
//! A router holds no weights itself. It reads a preset file naming models and
//! their flags, and starts a child `llama-server` per model that is asked for
//! -- so "one server per model, with that model's own settings" is what a
//! machine ends up running, and one process, one port and one record is what
//! this backend has to keep track of. That is the whole reason it is here:
//! before 2026-09-19 each session started its own `llama-server`, so two
//! sessions on one model held two copies of it in memory and a model change
//! cost a load that only that session benefited from.
//!
//! **A router outlives this backend, and nothing here stops it on its own.**
//! It is recorded the way a session's process is ([`process`]), adopted again
//! on the way back up, and ended only when somebody asks for that in the
//! machine's provider settings. A loaded model is minutes of disk and
//! gigabytes of memory; letting the last session to be closed throw that away
//! would make the shared server pointless.
//!
//! **The preset file is the configuration, and it lives on the serving
//! machine.** Flags that decide how a model is loaded -- context size, layers
//! on the GPU, slots, the draft head -- are per model rather than per session,
//! because one loaded model is what several sessions are now talking to. They
//! are written into a section named by the model's key, which is also the name
//! a request routes by, so nothing has to translate between the two.
//!
//! What is deliberately *not* here: which tools a session offers, how hard it
//! thinks, and the sampling settings. Those ride on each request, so they stay
//! the session's own and need no reload -- see `super`'s module comment.
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use serde_json::{Value, json};
use super::Model;
use crate::config::{MachineConfig, ProviderConfig};
use crate::session::process;
use crate::session::transport::{Launch, Streams, Transport};
/// Where a router's own output goes, both streams into one file. For a remote
/// machine this is the local end of the ssh connection, so it carries what the
/// far `llama-server` said -- including what a child model instance said,
/// which is the only account of a model that would not load.
const LOG: &str = "llama-router.log";
/// The preset file's name in the router's own directory, for a router on this
/// machine. One on another machine keeps it over there instead, at
/// [`REMOTE_PRESET`], since that is the only side that can read it.
const PRESET: &str = "models.ini";
/// The preset file's first line, which `llama-server` refuses a file without.
const VERSION: &str = "version = 1\n";
/// How long to wait for a model to load before giving up. Loading is mostly
/// disk, and a large quantised model on a cold cache is genuinely slow, so
/// this is generous -- the failure it exists for is a model that will never
/// answer rather than one that is slow.
const LOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// How long to wait for the router process itself. Short: it loads nothing,
/// so anything beyond a second or two is a port it cannot bind or a program
/// too old for one of these flags.
const START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// How often either of those is checked.
const POLL: std::time::Duration = std::time::Duration::from_millis(250);
/// How much of the router's log to carry into a message somebody reads on a
/// phone.
const LOG_TAIL_LINES: usize = 6;
/// How many models a router keeps loaded at once before evicting the least
/// recently used. One by default because the machine serving models usually
/// has one GPU: a second model loaded beside the first is the case where
/// neither fits.
const DEFAULT_MAX_LOADED: u32 = 1;
/// Every machine's router, so that two sessions on one machine reach one
/// process rather than starting two.
///
/// A registry rather than a field on each session: the sharing *is* the
/// point, and a router that two drivers could each own is one that both would
/// start.
pub struct Routers {
dir: PathBuf,
/// The runtime a router is started on and reaped into.
///
/// Captured here because everything that starts one runs on a *blocking*
/// thread -- loading a model is minutes of disk, so it cannot be on the
/// runtime -- and tokio's `Command::spawn` registers the child with the
/// reactor, so calling it outside a runtime context panics. That panic is
/// silent: it kills the loading thread and leaves the session saying
/// "loading" for ever, with nothing in the log, which is exactly how it
/// was found.
runtime: Option<tokio::runtime::Handle>,
inner: Mutex<HashMap<String, Arc<Router>>>,
}
impl Routers {
/// `dir` is where each router's record, log and (for this machine) preset
/// file live -- beside the session directories, since a router is shared
/// by sessions and belongs to none of them.
///
/// Made on the runtime that will outlive it; `None` is a test with no
/// runtime at all, where there is nothing to reap into either.
pub fn new(dir: PathBuf) -> Self {
Self {
dir,
runtime: tokio::runtime::Handle::try_current().ok(),
inner: Mutex::new(HashMap::new()),
}
}
/// This machine-and-provider's router, made if this is the first ask.
///
/// The machine and provider are re-read every time rather than captured,
/// because both are editable while sessions are running: a renamed
/// machine, a re-probed program, a changed `maxLoaded`. What a *running*
/// router was started with is whatever it was started with; the new value
/// reaches the next start, which is the same rule every other launch flag
/// follows.
pub fn of(&self, machine: &MachineConfig, provider: &ProviderConfig) -> Arc<Router> {
let key = format!("{}/{}", machine.id, provider.name);
let spec = Spec {
transport: Transport::for_machine(machine),
program: provider.program().to_string(),
max_loaded: provider.max_loaded.unwrap_or(DEFAULT_MAX_LOADED),
};
let mut routers = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let router = routers.entry(key.clone()).or_insert_with(|| {
Arc::new(Router {
dir: self.dir.join(key.replace('/', "-")),
spec: Mutex::new(spec.clone()),
runtime: self.runtime.clone(),
gate: Mutex::new(()),
})
});
*router.spec.lock().unwrap_or_else(|e| e.into_inner()) = spec;
Arc::clone(router)
}
}
/// What it takes to start a router, as its machine currently describes it.
#[derive(Clone)]
struct Spec {
transport: Transport,
program: String,
max_loaded: u32,
}
pub struct Router {
/// Its record and log, on this machine whichever machine it serves from.
dir: PathBuf,
spec: Mutex<Spec>,
/// See [`Routers::runtime`].
runtime: Option<tokio::runtime::Handle>,
/// Held while a router is started and while the preset file is edited --
/// the two things that go wrong when two sessions do them at once. Never
/// held across a model load, which takes minutes.
gate: Mutex<()>,
}
impl Router {
/// Where its record and log are, for a session that wants to watch the
/// process it is talking through or read what it last said.
pub fn dir(&self) -> &Path {
&self.dir
}
/// Its process record, if one is running -- which is also how a session
/// records the process it reaches its model through.
pub fn record(&self) -> Option<process::Record> {
process::live(&self.dir)
}
/// The same process, recorded as one a session reaches but does not own.
///
/// The one place that conversion happens, so that a session directory can
/// never come to hold a record saying it owns the machine's router -- see
/// [`process::Detail::Shared`].
pub fn shared_record(&self) -> Option<process::Record> {
let record = self.record()?;
match record.detail {
process::Detail::Http { port } => Some(process::Record {
detail: process::Detail::Shared { port },
..record
}),
process::Detail::Stdio { .. } | process::Detail::Shared { .. } => None,
}
}
/// Where to reach it, or `None` when nothing is running.
pub fn endpoint(&self) -> Option<String> {
match self.record()?.detail {
process::Detail::Http { port } => Some(format!("http://127.0.0.1:{port}")),
process::Detail::Stdio { .. } | process::Detail::Shared { .. } => None,
}
}
/// Puts `model` in memory and says where to talk to it, starting the
/// router first if it is not up.
///
/// Blocking, and slow on purpose: a model that has to come off disk takes
/// as long as it takes. The caller is the driver's loading thread, which
/// is what [`super::Serving::Loading`] exists to describe.
///
/// Nothing is held across that wait. Two sessions load through one router,
/// and the second one wanting a model already in memory must not queue
/// behind the first one's cold load of a different model -- which is most
/// of what sharing a server was for.
pub fn load(
&self,
key: &str,
found: &Model,
settings: &BTreeMap<String, String>,
) -> Result<String> {
let endpoint = self.start_if_down()?;
self.describe_model(key, found, settings)?;
// Nothing to ask for where it is already in memory, which is the case
// this whole module exists to produce: a second session naming a model
// somebody else loaded is a round trip rather than a load. Asking
// anyway is not harmless -- `POST /models/load` answers **400** for a
// model that is already loaded, which arrived as a session that
// refused to start next to one happily using that same model.
if !self.is_ready(key) {
let asked = self
.post("/models/load", json!({ "model": key }))
.with_context(|| format!("asking llama-server to load {key}"));
match (asked, self.wait_loaded(key)) {
// Loaded, whatever the request said: something else may have
// asked for it in the meantime, and what is in memory is the
// answer rather than what one request made of being told to
// put it there.
(_, Ok(())) => {}
// It did not load, and a refusal of the request itself says
// more about why than "it never appeared" does.
(Err(refused), Err(_)) => return Err(refused),
(Ok(_), Err(never)) => return Err(never),
}
}
Ok(endpoint)
}
/// Writes what this model is and how to load it into the preset file, and
/// has the router re-read it.
///
/// Also the path a settings change takes, which is why it is separate from
/// [`load`](Self::load): a model whose entry has changed is **unloaded**
/// by the re-read, and that is the change taking effect rather than a
/// side effect -- the sessions using it load it again, with the new
/// settings, on their next message. What must not happen is the same
/// thing to an unrelated model, which is why the file is written and the
/// re-read asked for only when the text actually differs.
pub fn describe_model(
&self,
key: &str,
found: &Model,
settings: &BTreeMap<String, String>,
) -> Result<()> {
// Read, edit, write: under the gate because two of those at once lose
// one of the two sections.
let _one_at_a_time = self.gate.lock().unwrap_or_else(|e| e.into_inner());
let existing = self.preset()?;
let updated = upsert(&existing, key, &section(found, settings));
if updated == existing {
return Ok(());
}
self.write_preset(&updated)?;
// Only meaningful against a running router; one that is down reads the
// file when it starts.
if self.endpoint().is_some() {
self.get("/models?reload=1")
.context("asking llama-server to re-read its models")?;
}
Ok(())
}
/// Whether this model is in memory now.
fn is_ready(&self, key: &str) -> bool {
self.loaded()
.into_iter()
.any(|model| model.model == key && model.ready)
}
/// Every model this router knows about and what each is doing, or an empty
/// list when it is not running.
pub fn loaded(&self) -> Vec<RouterModel> {
let Ok(answer) = self.get("/models") else {
return Vec::new();
};
answer
.get("data")
.and_then(Value::as_array)
.map(|models| models.iter().filter_map(RouterModel::read).collect())
.unwrap_or_default()
}
/// Takes one model out of memory, leaving the router and every other model
/// alone.
pub fn unload(&self, key: &str) -> Result<()> {
self.post("/models/unload", json!({ "model": key }))
.with_context(|| format!("asking llama-server to unload {key}"))?;
Ok(())
}
/// Ends the router and every model it is holding.
///
/// The only thing that does: a session closing, being deleted, or this
/// backend shutting down all leave it running. Sessions using it will see
/// their process go and report `exited`, which is true -- the model they
/// were talking to is no longer in memory.
///
/// Neither the record nor the mark is cleared here, and that is what tells
/// those sessions this was asked for rather than a crash. A session's
/// watcher looks a couple of seconds later, so anything removed now is
/// removed before the only reader of it has looked -- which is how the
/// first version of this put "llama-server exited" in three transcripts
/// belonging to somebody who had just pressed Stop. The record describes a
/// dead process, which every reader already handles, and starting a new
/// router is what clears both.
pub fn stop(&self) -> Result<()> {
let Some(record) = self.record() else {
return Ok(());
};
process::mark_stopping(&self.dir)?;
process::stop(&record, process::STOP_GRACE);
Ok(())
}
/// The end of the router's log, for a failure message.
///
/// Its account of what went wrong is the useful half -- "failed to load
/// model", "bind: Address already in use" -- and on a remote machine it is
/// the only half, since nobody reading the phone can open a file over
/// there. Bounded, because this ends up in an event a phone draws.
pub fn log_tail(&self) -> String {
let Ok(text) = std::fs::read_to_string(self.dir.join(LOG)) else {
return String::new();
};
let tail: Vec<&str> = text.lines().rev().take(LOG_TAIL_LINES).collect();
if tail.is_empty() {
return String::new();
}
format!(
" It last said: {}",
tail.into_iter().rev().collect::<Vec<_>>().join(" / ")
)
}
/// Adopts the running router or starts one, and waits for it to answer.
///
/// Under the gate, and asked again inside it: two sessions starting at once
/// would otherwise both find no record, both start a router, and bind two
/// ports to the same models.
fn start_if_down(&self) -> Result<String> {
if let Some(endpoint) = self.endpoint() {
return Ok(endpoint);
}
let _one_at_a_time = self.gate.lock().unwrap_or_else(|e| e.into_inner());
if let Some(endpoint) = self.endpoint() {
return Ok(endpoint);
}
let spec = self.spec.lock().unwrap_or_else(|e| e.into_inner()).clone();
wg_app_link::private::create_dir(&self.dir)?;
// Whatever the last router left behind, including the mark saying its
// end was asked for -- see [`stop`](Self::stop). From here on, a
// process that goes away is news.
process::clear(&self.dir);
// Written before the router starts, because it is read at startup and
// a router with no preset file at all lists nothing.
let preset = self.write_preset(&self.preset()?)?;
let forward = spec
.transport
.reserve_port()
.context("finding a port for llama-server")?;
let args = vec![
// Loopback there, whichever machine there is: what reaches it from
// outside that machine is the ssh tunnel and nothing else.
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
forward.there.to_string(),
// No `-m`: a `llama-server` given no model is a router.
"--models-preset".to_string(),
preset,
"--models-max".to_string(),
spec.max_loaded.to_string(),
// The built-in agent tools -- read, search, edit, shell. Hosted by
// the router itself, which is what makes them one set for the
// machine rather than one per model. Which of them a *session*
// offers its model is decided here in the backend, per request, so
// there is nothing per-session to pass through: see `super::tools`.
"--tools".to_string(),
"all".to_string(),
];
let launch = Launch::new(&spec.program, args, None).reaching(forward);
// Starting and reaping both happen inside the runtime, though this is
// a blocking thread: tokio's `Command::spawn` registers the child with
// the reactor, so calling it outside a runtime context panics -- and
// that panic kills only this thread, leaving a session that says
// "loading" for ever with nothing in the log. See [`Routers::runtime`].
let _inside = self.runtime.as_ref().map(tokio::runtime::Handle::enter);
// Its output goes to a file, not a pipe. Not only so the process can
// outlive this server: nothing ever read those pipes, so a chatty
// llama-server filled the 64 KB buffer and blocked with no sign of why.
let child = spec.transport.spawn(
&launch,
Streams::Detached {
stdin: std::process::Stdio::null(),
stdout: log_file(&self.dir.join(LOG))?.into(),
stderr: log_file(&self.dir.join(LOG))?.into(),
},
)?;
let pid = child
.id()
.context("llama-server exited before it could be recorded")?;
tracing::info!(
"running {} in router mode {} on 127.0.0.1:{} there, reached at 127.0.0.1:{} here, \
as pid {pid}",
spec.program,
spec.transport.describe(),
forward.there,
forward.here,
);
// Reaped so it does not become a zombie while this server is still
// its parent; the record and the health poll are what say whether it
// is alive, because after a restart there is no `Child` to ask.
if let Some(runtime) = &self.runtime {
runtime.spawn(async move {
let mut child = child;
let _ = child.wait().await;
});
}
// The *near* port, because that is the one anything reaching this
// router has to dial -- including a later run of this backend, which
// adopts the record without knowing which machine it is on. For a
// remote machine the recorded pid is the ssh client's, which is the
// process this machine owns and which holds the tunnel open for
// exactly as long as the far router lives.
let record = process::Record::of(pid, process::Detail::Http { port: forward.here })
.context("llama-server was gone before its start time could be read")?;
process::write(&self.dir, &record);
let endpoint = format!("http://127.0.0.1:{}", forward.here);
self.wait_answering(&endpoint)?;
Ok(endpoint)
}
/// Polls until the router answers, watching the process as well as the
/// port: a port already taken, or a `llama-server` too old for one of
/// these flags, exits within a second and would otherwise be waited out.
fn wait_answering(&self, endpoint: &str) -> Result<()> {
let deadline = std::time::Instant::now() + START_TIMEOUT;
let url = format!("{endpoint}/health");
loop {
if let Ok(response) = ureq::get(&url).call()
&& response.status() == 200
{
return Ok(());
}
match process::recorded(&self.dir) {
Some((_, process::Liveness::Alive | process::Liveness::Unknown)) => {}
Some((_, process::Liveness::Dead)) | None => {
process::clear(&self.dir);
bail!("llama-server exited before it answered.{}", self.log_tail());
}
}
if std::time::Instant::now() > deadline {
process::clear(&self.dir);
bail!(
"llama-server did not answer within {}s.{}",
START_TIMEOUT.as_secs(),
self.log_tail()
);
}
std::thread::sleep(POLL);
}
}
/// Polls until the named model is in memory, or says why it will never be.
///
/// A model that will not load is the common failure and it is fast: the
/// child exits, the router reports it unloaded with an exit code, and this
/// says so rather than waiting out the timeout -- which is what turned "it
/// said the file was corrupt" into "gave up after 300s".
fn wait_loaded(&self, key: &str) -> Result<()> {
let deadline = std::time::Instant::now() + LOAD_TIMEOUT;
loop {
match self.loaded().into_iter().find(|model| model.model == key) {
Some(model) if model.ready => return Ok(()),
Some(model) if model.failed => {
bail!("{key} would not load.{}", self.log_tail())
}
// Still loading, or not listed yet after a reload.
Some(_) | None => {}
}
if process::live(&self.dir).is_none() {
bail!(
"llama-server went away while loading {key}.{}",
self.log_tail()
);
}
if std::time::Instant::now() > deadline {
bail!(
"{key} was still loading after {}s.{}",
LOAD_TIMEOUT.as_secs(),
self.log_tail()
);
}
std::thread::sleep(POLL);
}
}
/// One request to the router's management endpoints. The generation ones
/// are the driver's and stream, so they are not these.
fn get(&self, path: &str) -> Result<Value> {
let url = format!("{}{path}", self.answering()?);
Self::read(
ureq::get(&url)
.call()
.with_context(|| format!("GET {path}"))?,
path,
)
}
fn post(&self, path: &str, body: Value) -> Result<Value> {
let url = format!("{}{path}", self.answering()?);
Self::read(
ureq::post(&url)
.send_json(body)
.with_context(|| format!("POST {path}"))?,
path,
)
}
/// Where to reach a router that is running, or the failure saying it is
/// not -- which is what every one of these requests needs first.
fn answering(&self) -> Result<String> {
self.endpoint().context("no llama-server is running")
}
fn read(mut response: ureq::http::Response<ureq::Body>, path: &str) -> Result<Value> {
response
.body_mut()
.read_json()
.with_context(|| format!("reading what {path} answered"))
}
/// The preset file as it stands on the machine that serves the models, or
/// empty where there is none yet.
///
/// Read back rather than remembered, for one reason that matters after a
/// restart: a router adopted from a previous run is already serving models
/// whose sections this process has never seen, and rewriting the file
/// without them would unload them at the next reload.
fn preset(&self) -> Result<String> {
let spec = self.spec.lock().unwrap_or_else(|e| e.into_inner()).clone();
let text = match &spec.transport {
Transport::Here => {
Ok(std::fs::read_to_string(self.dir.join(PRESET)).unwrap_or_default())
}
Transport::Ssh { name, .. } => {
let script = format!("p={REMOTE_PRESET}; cat \"$p\" 2>/dev/null || true");
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
spec.transport
.capture_blocking(&launch)
.with_context(|| format!("reading the model settings on {name}"))
}
}?;
// A file that is not there yet reads as a new one rather than as
// nothing: `llama-server` refuses a preset with no version line, so
// "empty" is not a state this can hand back or write.
Ok(if text.trim().is_empty() {
VERSION.to_string()
} else {
text
})
}
/// Writes the preset file where the router will read it, and says where
/// that is -- which is the path the router is given, so the two cannot
/// disagree.
fn write_preset(&self, text: &str) -> Result<String> {
let spec = self.spec.lock().unwrap_or_else(|e| e.into_inner()).clone();
match &spec.transport {
Transport::Here => {
let path = self.dir.join(PRESET);
wg_app_link::private::write_file(&path, text.as_bytes())?;
Ok(path.to_string_lossy().into_owned())
}
Transport::Ssh { name, .. } => {
use base64::Engine as _;
// Base64 rather than a heredoc: the text goes through a shell
// on the far side, and an INI value is not something to trust
// to quoting rules twice over.
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
let script = format!(
"p={REMOTE_PRESET}; mkdir -p \"$(dirname \"$p\")\" && \
printf %s \"$1\" | base64 -d > \"$p\" && printf '%s\\n' \"$p\""
);
let launch = Launch::new(
"sh",
vec!["-c".to_string(), script, "sh".to_string(), encoded],
None,
);
let answer = spec
.transport
.capture_blocking(&launch)
.with_context(|| format!("writing the model settings on {name}"))?;
match answer.trim() {
"" => bail!("{name} did not say where it wrote the model settings"),
path => Ok(path.to_string()),
}
}
}
}
}
/// Where the preset file goes on a machine that is not this one, as a shell
/// word the far side expands: under that machine's state directory, beside
/// whatever else belongs to this app there.
///
/// `$HOME` is resolved over there because only that machine knows what it is.
const REMOTE_PRESET: &str = "\"${XDG_STATE_HOME:-$HOME/.local/state}/ai-app/llama-models.ini\"";
/// One model the router knows about, as the provider view and the load poll
/// both read it.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RouterModel {
pub model: String,
/// The router's own word: `unloaded`, `loading`, `loaded`, `sleeping`.
/// Carried through rather than reduced to a boolean, because the phone
/// draws it and llama.cpp is the authority on what states there are.
pub status: String,
pub ready: bool,
/// Unloaded *and* something went wrong, which is not the same as unloaded.
#[serde(skip)]
pub failed: bool,
}
impl RouterModel {
fn read(entry: &Value) -> Option<Self> {
let model = entry.get("id")?.as_str()?.to_string();
let status = entry
.pointer("/status/value")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let exited = entry
.get("exit_code")
.and_then(Value::as_i64)
.unwrap_or_default();
Some(Self {
ready: status == "loaded" || status == "sleeping",
failed: status == "unloaded" && exited != 0,
model,
status,
})
}
}
/// What a model's section says, from the file it is and the settings it has.
///
/// The keys are `llama-server`'s own argument names without their dashes,
/// which is what a preset section is: `--n-gpu-layers 20` is
/// `n-gpu-layers = 20`. So adding a setting is a row here and a row in
/// [`crate::config::LLAMA_MODEL_PARAMS`], and nothing in between.
fn section(found: &Model, settings: &BTreeMap<String, String>) -> String {
let mut lines = vec![format!("model = {}", found.path)];
for (key, flag) in [
("contextSize", "ctx-size"),
("gpuLayers", "n-gpu-layers"),
("threads", "threads"),
// How far ahead the draft head guesses. Not defaulted: 2 measured 7%
// faster than llama.cpp's 3 on this machine's GPU, once, which is a
// reason to make the knob reachable and not a reason to move it for
// everybody.
("specDraftNMax", "spec-draft-n-max"),
] {
if let Some(value) = settings.get(key).map(|value| value.trim())
&& !value.is_empty()
{
lines.push(format!("{flag} = {value}"));
}
}
// One slot unless this model is told otherwise. A session is one
// conversation making one request at a time, and a second session's turn
// waits rather than splitting the cache: measured 2026-09-19 on the 27B
// here, 41.5 tok/s plain at any slot count, **61.4** with the draft head
// at one slot, and **28** with the head at four. Speculating against a
// split KV cache is slower than not speculating at all.
let slots = settings
.get("slots")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.unwrap_or("1");
lines.push(format!("parallel = {slots}"));
// A model carrying a multi-token-prediction head drafts with it, which is
// most of a 50% speed-up for free -- the tensors are in the file whether
// or not they are used. Conditional because it cannot be otherwise: asked
// for on a model without one, `llama-server` **exits** ("context type MTP
// requested but model doesn't contain MTP layers"). See `Model::mtp`.
if found.mtp && settings.get("speculative").map(String::as_str) != Some("off") {
lines.push("spec-type = draft-mtp".to_string());
}
lines.join("\n")
}
/// The preset file with `name`'s section replaced by `body`, added at the end
/// if it was not there.
///
/// Text in and text out, rather than a parsed model, because the file belongs
/// to `llama-server` rather than to this: anything in it that this does not
/// understand -- a `[*]` section, a key added by a later version, a comment
/// somebody wrote -- has to survive being edited.
fn upsert(existing: &str, name: &str, body: &str) -> String {
let header = format!("[{name}]");
let mut out = String::new();
let mut skipping = false;
let mut replaced = false;
for line in existing.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
skipping = trimmed == header;
if skipping {
replaced = true;
push_section(&mut out, &header, body);
continue;
}
}
if !skipping {
out.push_str(line);
out.push('\n');
}
}
if out.is_empty() {
out.push_str(VERSION);
}
if !replaced {
push_section(&mut out, &header, body);
}
out
}
fn push_section(out: &mut String, header: &str, body: &str) {
if !out.ends_with("\n\n") && !out.is_empty() {
out.push('\n');
}
out.push_str(header);
out.push('\n');
out.push_str(body.trim_end());
out.push_str("\n\n");
}
/// An owner-only log opened for appending, so the two streams pointed at it do
/// not overwrite each other and an adopted router keeps what came before.
fn log_file(path: &Path) -> Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(path)
.with_context(|| format!("opening {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect()
}
#[test]
fn a_section_names_the_file_and_the_flags_that_were_set() {
let found = Model {
path: "/models/a.gguf".to_string(),
mtp: true,
};
let text = section(
&found,
&settings(&[("contextSize", "8192"), ("threads", " 6 ")]),
);
assert_eq!(
text,
"model = /models/a.gguf\nctx-size = 8192\nthreads = 6\nparallel = 1\n\
spec-type = draft-mtp"
);
// A blank is not a value: it is the setting being unset, and passing
// it on is a child that exits on an empty argument.
let text = section(&found, &settings(&[("contextSize", " ")]));
assert!(!text.contains("ctx-size"), "{text}");
// The draft head is asked for only where the file has one, and can be
// turned off for a machine where it does not pay.
let plain = Model {
mtp: false,
..found.clone()
};
assert!(!section(&plain, &settings(&[])).contains("spec-type"));
assert!(!section(&found, &settings(&[("speculative", "off")])).contains("spec-type"));
}
#[test]
fn a_section_replaces_its_own_and_leaves_every_other_line_alone() {
let first = upsert("", "repo/a.gguf", "model = /models/a.gguf");
assert!(first.starts_with("version = 1\n"), "{first}");
assert!(
first.contains("[repo/a.gguf]\nmodel = /models/a.gguf\n"),
"{first}"
);
// A second model is added rather than replacing the first, because a
// reload of a file that lost a section unloads that model -- which
// would be one session taking another's model out of memory.
let both = upsert(&first, "repo/b.gguf", "model = /models/b.gguf");
assert!(both.contains("[repo/a.gguf]"), "{both}");
assert!(both.contains("[repo/b.gguf]"), "{both}");
// Editing one rewrites only its own keys, and keeps what llama.cpp's
// own file has that this does not know about.
let with_global = format!(
"version = 1\n\n[*]\njinja = true\n\n{}",
both.trim_start_matches("version = 1\n")
);
let edited = upsert(
&with_global,
"repo/a.gguf",
"model = /models/a.gguf\nctx-size = 4096",
);
assert!(edited.contains("[*]\njinja = true"), "{edited}");
assert!(edited.contains("ctx-size = 4096"), "{edited}");
assert_eq!(edited.matches("[repo/a.gguf]").count(), 1, "{edited}");
assert!(
edited.contains("[repo/b.gguf]\nmodel = /models/b.gguf"),
"{edited}"
);
}
#[test]
fn writing_the_same_settings_twice_changes_nothing() {
// What keeps an unrelated session's model in memory: the file is only
// written, and the router only told to re-read it, when the text
// actually differs.
let once = upsert("", "repo/a.gguf", "model = /models/a.gguf");
assert_eq!(upsert(&once, "repo/a.gguf", "model = /models/a.gguf"), once);
}
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more