Author SHA1 Message Date
iris-ai cedb18e8c1 Draw a background task as what it ran, and go there on a tap
A card in the session's panel said "background command" under every
description -- and for Codex, which names a terminal by a process id and
gives no description at all, that phrase was the whole of every card.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two other things the same screens wanted:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Every provider setting is reachable.** `-np 1`, the MTP draft depth, the
tool set, the sampling parameters -- most were hardcoded to what measured
best on this machine, which is right as a default and wrong as a constant:
the next machine has a different GPU and a different core count, and
nobody running this app can edit the source. `DriverKind::params` now
declares what a provider takes -- key, label, shape, what blank means, and
whether a change waits for a restart -- and the phone renders whatever
arrives, on the spawn form and in the session settings dialog. Adding a
setting to a driver is one entry in that table and no app change.
`POST /sessions/{id}/params` takes the whole map, so an absent key is the
instruction to unset; the sampling half applies at once and the session is
told in words which of the rest are waiting for a restart.

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

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

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

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

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

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

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

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

Also here, because tools made each of them matter:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

What remains is the actual defect and the smallest thing that fixes it. The
fold still refuses to grow a settled reply, so a turn boundary is always a
message boundary, and where two replies then abut it puts a `TurnBreak`
between them: a hairline, no words, no colour. Made by the fold rather than
sent by the server, because it is not something that happened -- it is the
boundary between two things that did. `joinPages` puts one in at a page seam,
which the fold never gets to see.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:10:21 -04:00
102 changed files with 24478 additions and 4196 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
../../.claude/skills/ai-app-rigs
+110 -18
View File
@@ -72,13 +72,19 @@ Each exists because something was invisible without it.
`usage::Fixture`'s, since those are its states. With none set an echo `usage::Fixture`'s, since those are its states. With none set an echo
session meters nothing, which is the ordinary case and draws no bar. session meters nothing, which is the ordinary case and draws no bar.
- **A fake CLI exercises the process lifecycle without a token.** Point a - **A fake CLI exercises the process lifecycle without a token.** Point a
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and `claude_cli` provider's `command` at a script that ordinarily runs
`cat > /dev/null` and it behaves the way the lifecycle code cares about: `cat > /dev/null` and it behaves the way the lifecycle code cares about:
it holds the fifo open, records a real pid, writes nothing, and dies on a it holds the fifo open, records a real pid, writes nothing, and dies on a
signal. So adopt, stop, restart and start are all drivable without a real signal. So adopt, stop, restart and start are all drivable without a real
`--resume` and without spending a turn on somebody's account. Reach for `--resume` and without spending a turn on somebody's account. Reach for
this when what is under test is *whether a process is running*, and for this when what is under test is *whether a process is running*, and for
`debug-transcript.sh` when it is *what the transcript draws*. `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 - **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
the first session (or `-k` keeps the current screen), scrolls a fixed the first session (or `-k` keeps the current screen), scrolls a fixed
gesture loop, and prints the app's render report — the same one the in-app gesture loop, and prints the app's render report — the same one the in-app
@@ -141,28 +147,63 @@ moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
### Testing llama.cpp and ssh here ### Testing llama.cpp and ssh here
**Both are set up here as of 2026-09-04** and need nothing typed. The **Both are set up here** and need nothing typed. The prebuilt llama.cpp lives
prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp` outside the repo at `~/.local/opt/llama.cpp-vk` — a **Vulkan** build as of
(the 15 MB `ubuntu-x64` release asset) and is symlinked as 2026-09-19, replacing the CPU one that was there before — and is symlinked as
`/usr/local/bin/llama-server`, which is what makes **discovery find it over both `~/.local/bin/llama-server` and `/usr/local/bin/llama-server`. The second
ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets. is what makes **discovery find it over ssh**: `~/.local/bin` is not on the
It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is PATH a non-interactive ssh session gets. It resolves its own libraries through
needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, `$ORIGIN`, so no `LD_LIBRARY_PATH` is needed.
639 MB under `~/.local/share/ai-app/models` — and answers at usable speed on
this VM's 8 cores. **Do not test with a 2-bit quant**: the Two models are downloaded under `~/.local/share/ai-app/models`:
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 - `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, 639 MB, loads in ~4s. It
is how to tell the two apart in a hurry. calls tools correctly and is the right rig for the driver's shape. Do not
judge *answers* by it — asked for the second line of a file it read from
line 2 and then named the third.
- `ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF/Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf`,
12 GB, ~20s to load, and the only one here with a multi-token-prediction
head. It is the rig for anything about `loading` being a state of its own,
since 20s is long enough to send into.
- `ggml-org/SmolVLM-256M-Instruct-GGUF/SmolVLM-256M-Instruct-Q8_0.gguf`,
175 MB, plus the `mmproj-…` beside it, downloaded 2026-09-20 as the rig for
**vision**: it is the only model here that reads pictures, it loads in
seconds on the CPU, and it described a red circle correctly. The pair is
also what exercises the projector being found beside the weights, and the
projector being kept out of the models a provider offers. Qwen3-0.6B beside
it is the other half of that rig -- the model that answers `refused`.
**Do not test with a 2-bit quant**: the IQ2_XXS of the 0.6B produces fluent
nonsense, which reads exactly like a broken driver — `llama-cli` produces the
same from the file directly, which is how to tell the two apart in a hurry.
**The GPU is shared and llama-server dies loudly when it runs out.** A second
server loading a model while the 27B holds VRAM fails with `radv/amdgpu:
Failed to allocate a buffer` / `MESA: error: buffer allocation failed` and
exits mid-request. `-ngl 0` runs it on the 8 cores instead, which is the way
to test the driver while something else holds the card -- through the app, that
is the model's "Layers on the GPU" set to 0 in the machines tab's provider
view, and `--models-max` above 1 is how two models come to be loaded at once
in the first place.
**Testing tools and MCP without the app**: `llama-server --tools all` publishes
its built-in tools at `GET /tools` and runs one at `POST /tools` with
`{"tool": …, "params": …}` and an `x-tool-cwd` header — so a whole agent loop
is drivable with `curl` and no model at all. The Exa MCP server at
`https://mcp.exa.ai/mcp` answers **without an API key** and needs a
`User-Agent` header (Cloudflare answers 403 without one, which reads as a
refusal rather than a missing header).
There is no second machine, so **ssh this VM to itself**. That is set up There is no second machine, so **ssh this VM to itself**. That is set up
too: the key is `~/.config/ai-app/ssh-self` (its public half is in too: the key is `~/.config/ai-app/ssh-self` (its public half is in
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a `~/.ssh/authorized_keys`, labelled removable), and the real config carries a
setup called **"this vm over ssh"** — `bob@127.0.0.1` with that machine called **"this vm over ssh"** — `bob@127.0.0.1` with that
`identityFile` plus `identityFile` plus
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]` `options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
whole rig for "does a remote llama session work", since the far machine is whole rig for "does a remote llama session work", since the far machine is
this one and the model file is the same file. For a throwaway setup of your 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` own, point a provider's `command` at something harmless like `/bin/echo`
rather than at `claude`: the transport is what is under test, the process rather than at `claude`: the transport is what is under test, the process
exiting immediately is the signal, and it costs no tokens. The remote login exiting immediately is the signal, and it costs no tokens. The remote login
@@ -200,7 +241,7 @@ never be able to close the app, whatever produced it.
**Deleting a session offers to take the machine's own transcript with it** **Deleting a session offers to take the machine's own transcript with it**
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
confirmation, and only where the driver keeps a record of its own confirmation, and only where the driver keeps a record of its own
(`keepsOwnTranscript`, which today means Claude Code). Off by default, (`keepsOwnTranscript`, currently Claude Code or Codex). Off by default,
because leaving that copy is what makes an ordinary delete recoverable — and because leaving that copy is what makes an ordinary delete recoverable — and
the dialog's paragraph is rewritten when it is on rather than appended to, the dialog's paragraph is rewritten when it is on rather than appended to,
since the sentence promising the conversation "should still be there to since the sentence promising the conversation "should still be there to
@@ -210,6 +251,57 @@ where it was instead of half-deleted.
## Measurements worth not re-taking ## Measurements worth not re-taking
- **`-np 1` is what makes the MTP draft head pay.** Taken 2026-09-19 on the
27B above, decode speed for a 300-token reply, from `llama-server`'s own
timings rather than the clock:
| flags | tok/s |
| --- | --- |
| plain, any `-np` | 41.5 |
| `--spec-type draft-mtp -np 1` | 61.4 |
| `--spec-type draft-mtp -np 2` (n-max 2) | 65.9 |
| `--spec-type draft-mtp`, default `-np` (4 slots) | 28 |
Draft acceptance is 0.530.73 in every case, so the head is working in all
of them: what changes is that speculating against a KV cache split four ways
is slower than not speculating. A model's preset gets `parallel = 1` unless
its settings say otherwise (the machines tab's provider view, since
2026-09-19), so this is recorded for whoever next sees MTP look broken or
next raises the slot count to answer two sessions at once. `--spec-draft-n-max 2` was
worth another 7% in a single sample and is deliberately *not* passed — one
sample on a virtualised GPU is not a number to hardcode.
- **Prompt processing is the expensive part of a llama turn here, and decode
speed falls only slowly with context.** Taken 2026-09-19 on a free GPU, the
27B with `--spec-type draft-mtp -np 1`, generating 160 tokens each time:
| context | decode | prefill of that prompt |
| --- | --- | --- |
| 88 | 43.4 tok/s (cold) | 21s |
| 1,569 | 55.5 tok/s | (model still warming) |
| 6,068 | 53.2 tok/s | 9.5s |
| 14,068 | 50.3 tok/s | 22s |
So a turn on a long conversation spends tens of seconds before the first
token, and that is what `SessionStatus::Reading` exists to say. The same
sweep on the 0.6B **on the CPU** falls much harder -- 30.1 tok/s at 44
tokens of context to 11.5 at 6,024 -- which is the shape somebody means by
"it gets slower as the conversation goes on". The figure the app draws is
`timings.predicted_per_second`, decode only, so prefill is never mixed into
it.
- **A busy GPU is a model that will not load at all**, not a slow one:
`radv/amdgpu: Failed to allocate a buffer` and `failed to load model` while
something else holds VRAM. A 0.6B that had been decoding at 149 tok/s ran at
16.7 in that window before its server died, so a tok/s figure taken while
the card is shared says nothing about the model.
- **Asking for the head when the file has none is fatal**, not ignored:
`context type MTP requested but model doesn't contain MTP layers` and the
server exits. Without the flag the same file logs `unused tensor
blk.N.nextn.* — ignoring` and runs normally, which is the state to look for
when MTP is silently not happening.
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU - **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
emulator against a real imported transcript with the server at emulator against a real imported transcript with the server at
`--delay 120`. Settled and flinging fast, both into fresh history and back `--delay 120`. Settled and flinging fast, both into fresh history and back
+288 -11
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.
@@ -34,7 +34,25 @@ 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 llama.cpp session runs on whatever machine its setup names** (built **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 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 port the server binds *there* and the port that reaches it *here*, and
`Launch::reaching` puts the `-L` tunnel on the connection already carrying `Launch::reaching` puts the `-L` tunnel on the connection already carrying
@@ -43,14 +61,110 @@ Module-by-module intent is in PLAN.md's "Backend layout".
because `llama-server` never reads the stdin whose closing ends a CLI and 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 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 the machine that will serve it, so the spawn screen offers
`GET /setups/{id}/models` rather than the backend's own downloads; and the `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 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 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". 300s". See PLAN.md's "Transport" and "llama-server management".
**A llama session has tools and runs the loop itself** (2026-09-19):
`--tools all` gives the router `llama-server`'s built-in set, which it also
*runs* (`GET /tools` for the definitions, `POST /tools` to call one), while
web search comes from an MCP server this backend connects to directly
(`session/llama/mcp.rs`, Exa preset in a discovered provider's
`mcpServers`). Driving the loop is what makes the permission gate ours:
`manual` asks before every call and remembers a tool you answer
"Always allow …" to, `bypassPermissions` never asks, and the allowances are
folded back out of the transcript. Which tools a *session* offers is a
filter applied to those definitions here, not a flag over there: one shared
server has one set, and the filter costs no reload (2,181 tokens of prompt
with all seven, 698 with none). Three more things fall out of it and are
easy to get wrong again — a model change **asks for another model** and
stops nothing, since the one being left may be another session's;
`parallel = 1` unless that model's settings say otherwise, and it is what
decides whether the MTP draft head is a 50% speed-up or a 33% loss; and
`spec-type = draft-mtp` is conditional on the file actually having a head,
because asking for one that is not there makes `llama-server` **exit**.
**A llama session takes a picture only where the model natively reads one**
(2026-09-20): a multimodal model is loaded with the `mmproj` found beside
its weights (overridable per model, `off` included), an attachment rides in
the request as an `image_url` data URI, and nothing at all is done for a
model without a projector. Four things fall out of it and are easy to get
wrong again -- whether a session takes pictures is `/props`'s
`modalities.vision` from the loaded server and never a guess from this side,
with three states because a loading model has not answered yet
(`Images::Unknown` is *offered*, since a control withheld because nobody
could ask is missing from sessions that would have taken it); a message
carrying an image a model cannot read is **stopped rather than stripped**,
refused at the door, at the queue and at the steering boundary, because
`llama-server` refuses the whole request over one part and a message sent
without its picture is a different message; an earlier turn's image folds
into a line of words for a model without vision, so switching models does
not end the conversation; and a projector is filtered out of the models a
provider *offers*, while staying in the machine's own model list.
**A llama session's thinking is drawn** (2026-09-19): `reasoning_content`
becomes `Event::Thinking` deltas closed by an `Event::ThinkingDone` carrying
the span the *driver* measured, and the phone draws a card that spins while
the block is open and says "Thought for 12.4s" once it is not. The reasoning
is deliberately not part of the next prompt (`conversation` ignores it), and
`timings.predicted_per_second` and `timings.prompt_ms` off the same stream
become `UsageDelta`'s `tokensPerSecond` and `prefillMs`, which is the
"read 9.5s · 50.3 tok/s · 3:00 PM" under a finished reply — nothing else here
measures either, so every other driver sends `None`, and the clock is last so
that it does not move when a provider reports fewer of them.
**A 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
@@ -60,7 +174,7 @@ 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 - `SUBAGENTS.md` — a session's subagents as transcripts of their own
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt` (`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
@@ -171,7 +285,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.
@@ -204,6 +318,11 @@ 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
@@ -228,6 +347,71 @@ PLAN.md's "Auto-resume" is the design; day to day:
backend restart. A day after the limit was hit it gives up and says so in backend restart. A day after the limit was hit it gives up and says so in
the transcript. the transcript.
## A session waiting on its own work
Since 2026-09-06 a session whose turn ended with a **backgrounded subagent or
command still running** reports `waiting` rather than `idle` — its own status,
drawn as the word "waiting" in `waitingColor` on both screens. `idle` means
"waiting for a person" and this means the opposite, so it also suppresses the
"finished" notification, which used to arrive at the one moment it was untrue.
Two things fall out of it and are easy to get wrong again: 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 the subagent finishes; and
`sessionWorking("waiting")` is deliberately **false** — nothing is being
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. **A task is drawn as the command it ran, and tapping it
goes to the call that started it** -- both from the transcript rather than
from the provider: a driver reports which tool call its task belongs to and
`Session::background_tasks` resolves that id into a seq and, for a provider
that says nothing (Codex names a terminal by a process id), the command on
the call. The phone travels there with `travelTo`, the same journey a
reopened session makes to put a reader back where they stopped. An `ambient` task is dropped from both the list and the
count, on the CLI's own instruction: a live-update watcher is not activity,
and counting one leaves a session `waiting` for ever. An adopted CLI is sent a repeated `initialize` to ask
for the current set. Reconcile only between turns or at a result boundary:
a foreground agent is legitimately absent from a background-only snapshot.
Older CLIs still need both edge sources: `open_tasks` knows about a
backgrounded command, while `Subagents::any_open` finds a subagent whose
`task_started` is behind an adopted stdout offset.
- **A usage limit a subagent hits reaches the session**, not just the
subagent's own transcript; auto-resume can only schedule against a session.
That is the case where the main agent is idle and a background Task is
still burning quota.
- **Codex's count is two id sets added together.** Open child thread ids come
from the subagent registry; live background command process ids come from
app-server's experimental `thread/backgroundTerminals/list`. The command set
is runtime state, refreshed at lifecycle edges and once a second while
nonempty. Never decrement it from an unmatched completion.
- **The status word and its colour are `sessionStatusWord` /
`sessionStatusColour`**, shared by the list and the session screen. They
were two `when`s, and the second one silently missed `waiting`.
## Shared appearance ## Shared appearance
- **A row something is happening to is dimmed, drained of colour, and says - **A row something is happening to is dimmed, drained of colour, and says
@@ -252,8 +436,80 @@ PLAN.md's "Auto-resume" is the design; day to day:
## 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 path is stored as it was typed, and `~` is expanded where it is used.**
`~/repos/x` and `/home/someone/repos/x` are a path and a snapshot of where it
pointed, and the snapshot is what breaks when an account is renamed or the
value is read on another machine -- so nothing at the boundary rewrites one
in either direction (`machines::tidy` used to expand and `shorten_home` used
to contract; both are gone). Expansion belongs to the machine the path is on:
`ssh::quote_path` and `files::PATH_PRELUDE` for a remote one,
`ssh::expand_home` for one here. The exception that proves it is
**`llama-server`'s tools**, which take the working directory as an
`x-tool-cwd` header and `chdir` to it with no shell in the way: a `~` arrives
there as a directory of that name and *every* tool using one answers "failed
to spawn process\n[exit code: -1]", which on the phone looks like a session
whose tools are all broken. `files::resolve_blocking` is what the llama
driver resolves it with at launch, on the machine that will serve the
session.
- **A transcript outlives the enum.** Removing `Event::TaskNote` hours after
adding it made every transcript that had recorded one unreadable, so
`launch` failed for those sessions and `SessionManager::new` skipped them —
no status, nothing sendable, no new messages, for every live session that
had run a background task. **The set of kinds a transcript can hold only
ever grows**: a line may come from a newer server or from an older one that
wrote a kind since dropped, and one unfamiliar word must never be able to
end the file. `Indexed::parse_at` degrades a line it cannot read to
`Event::Unreadable { kind }`, keeping its seq — which is what everything
downstream is addressed by — and the phone draws it as a placeholder saying
which kind. Never delete a variant instead of retiring it; `Event::TaskNote`
is what retiring looks like, and the phone folds it to no row.
Project-specific only. A lesson that would bite any project on this machine
belongs in `~/.claude/MACHINE.md` or the `this-machine-*` skill for its
subject; one that would bite any project anywhere belongs in the
`code-lessons` skill, under the admission test at its end.
- **tracing caches callsite interest process-wide.** A test that hits a - **tracing caches callsite interest process-wide.** A test that hits a
`tracing::warn!` with no subscriber installed can poison the interest cache `tracing::warn!` with no subscriber installed can poison the interest cache
@@ -347,6 +603,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,
@@ -354,4 +623,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.**
`loadOlderPage` joined its page to `items`, suspended while `warm` parsed
markdown, and then assigned the joined snapshot. An SSE event arriving in
that gap appeared and vanished; reopening brought it back because the
transcript and cache had it all along. Warm against a candidate if needed,
then join against the current `items` and assign without another suspension.
Also keep the page's original `oldestSeq`: a stream reset while the fetch or
warm is suspended makes the page stale, and it must be discarded rather
than joined into the reset window.
+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`
+951 -92
View File
File diff suppressed because it is too large. Load diff
+195 -47
View File
@@ -1,68 +1,159 @@
# Subagents # Subagents
A session's subagents -- the helpers a Claude Code session starts through its A session's subagents -- helpers started by Claude Code's Task tool or Codex's
Task tool -- each get a transcript of their own, listed under the session's collaboration tools -- each get a transcript of their own, listed in a panel
card and readable in the same transcript view the session has. Designed over the open session and readable in the same transcript view the session has.
2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`. 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 ## What a subagent is here
**A subagent is a second transcript owned by a session, in the same event **A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls.** It is not a session: it cannot be model, with no process and no controls.** It is not a session: it cannot be
messaged, stopped or started, and it has no setup, model or usage of its 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 own. Everything it shares with a session -- the transcript file format, the
paging routes, the SSE stream, the phone's cache and rendering -- is reused paging routes, the SSE stream, the phone's cache and rendering -- is reused
by addressing, not by copying. by addressing, not by copying.
The CLI reports a subagent's messages on the parent's own stream-json Claude reports a subagent's messages on the parent's own stream-json output,
output, each carrying `parent_tool_use_id` = the id of the Task `tool_use` each carrying `parent_tool_use_id` = the id of the Task `tool_use` that started
that started it. Before this the translator dropped those lines it. Before this the translator dropped those lines
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes (`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
them to that subagent's own translator and transcript. The parent's them to that subagent's own translator and transcript. The parent's
transcript still shows only the Task call itself. transcript still shows only the Task call itself.
Codex app-server multiplexes every thread in the session tree onto the root
process's stdout. Its notifications carry `threadId`; `subAgentActivity`
items name the child thread and its lifecycle, and `collabAgentToolCall`
items carry the spawn prompt. The Codex translator routes a non-root
`threadId` exactly as Claude routes a `parent_tool_use_id`. The child thread
id is the subagent id on disk. An asynchronously delivered `agentMessage` is
a `PeerMessage`, not assistant text from the recipient. Its delta notification
does not repeat the completed item's `delivery` field, so the translator
remembers that field from `item/started` and suppresses those deltas. Letting
one into the recipient's provisional assistant row makes its next completed
message replace the combined row, visibly erasing text that Codex still has.
The parent draws the initial `spawnAgent` as its ordinary `Task` card and
closes it when the matching `subAgentActivity.started` arrives. The remaining
collaboration calls remain visible as coordination -- waiting, messaging,
listing and lifecycle controls -- rather than being mistaken for generic task
output. Null optional fields and a bare `completed` status carry no information
and are omitted; their useful result is the child transcript, status or peer
message beside them.
## Storage ## Storage
Under the session directory: Under the session directory:
``` ```
<session>/subagents/<tool_use_id>/meta.json {title, created} <session>/subagents/<subagent_id>/meta.json {title, created}
<session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's <session>/subagents/<subagent_id>/transcript.jsonl same SeqEvent lines as the session's
``` ```
The id is the Task tool_use id (`toolu_…`), which is unique, stable across a The id is Claude's Task tool_use id (`toolu_…`) or Codex's child thread id.
backend restart, and already the key everything on the parent side uses. 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 Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
id becomes a path. id becomes a path.
The transcript's sequence numbers are its own, starting at 1. `Transcript`, The transcript's sequence numbers are its own, starting at 1. `Transcript`,
`read_window`, `catch_up` and `read_after` work on it unchanged. `read_window`, `catch_up` and `read_after` work on it unchanged.
Its path out: deleting the session deletes its directory, subagents included. Its path out: deleting the session deletes its directory, subagents included,
There is no separate delete. 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 ## Lifecycle, as events in the subagent's transcript
1. Created on the first child line for an unseen parent id (or, when the 1. Created when the parent Task/Agent call is seen. A current Claude CLI's
parent Task call was seen, at that call). First lines written: `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 `Status Running`, then `UserMessage { text: <the Task's prompt> }` when
the prompt is known -- it genuinely is the subagent's first user turn. the prompt is known -- it genuinely is the subagent's first user turn.
2. Every child line is translated by that subagent's own `Translator` 2. Every child line is translated by that subagent's own `Translator`
(one per subagent: tool ids are unique but streaming deltas are by (one per subagent: tool ids are unique but streaming deltas are by
content-block index, and parallel subagents interleave). content-block index, and parallel subagents interleave).
3. **The parent's `tool_result` never finishes a subagent.** The Task tool 3. **What ends a subagent is the CLI's own task lifecycle**, on top-level
runs in the background by default: the `tool_result` -- "Async agent `system` lines that carry no `parent_tool_use_id`: `task_started`
launched..." -- arrives the moment it *starts*, while the subagent goes (`task_id`, `tool_use_id`, `task_type`, `is_backgrounded`, the prompt),
on working for however long its own turn takes, sometimes minutes. What `task_progress` repeatedly, then `task_updated` (`patch.status`, naming the
ends it is its own turn ending: the raw API's `message_delta` on its *task* only) and `task_notification` (`tool_use_id`, `status`, and `summary`
stream carrying `stop_reason: "end_turn"` (a `stop_reason` of `tool_use` -- the agent's own report). `translate_task` keeps the
is the model about to call one, not an end), or a `result` line for its `task_id -> tool_use_id` mapping from the first so the update can be
own turn if a future CLI version ever sends one. Either maps to attributed, records the summary as the subagent's closing text, and writes
`Status Exited`; the subagent's vocabulary has no `Idle`, so the `Status Exited`. A
equivalent event `dispatch` produces for an ordinary session is dropped `completed` update is deliberately not the end: its notification carries
rather than written. A shipped version of this finished on the the summary and would otherwise land after the ending. Any other terminal
`tool_result` instead, which read a running background agent as status ends it from the update, since the failure to avoid is a subagent
"finished" with its transcript truncated at the moment it launched. 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** 4. **A child line for a subagent that already finished reopens it**
(`Status Running`) rather than being dropped: a background Task can be (`Status Running`) rather than being dropped: a background Task can be
sent another message long after its first turn ended, and that is sent another message long after its first turn ended, and that is
@@ -70,7 +161,27 @@ There is no separate delete.
`Translator`, just picking back up. `Translator`, just picking back up.
5. When the parent session's process exits (`Status Exited` on the 5. When the parent session's process exits (`Status Exited` on the
session), every subagent still `Running` gets `Status Exited` too: its session), every subagent still `Running` gets `Status Exited` too: its
process was the parent's. 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: A subagent that was mid-flight when the backend restarted keeps working:
the registry reopens the existing transcript on the next child line, and the registry reopens the existing transcript on the next child line, and
@@ -80,27 +191,34 @@ the backend was down nothing recorded that until the next line arrives, so
its last status stays `Running`, which the list reports as **unknown** its last status stays `Running`, which the list reports as **unknown**
rather than as running (see the wire shape) until then. rather than as running (see the wire shape) until then.
Title: the Task call's `description` input, then ` (<subagent_type>)` when Title: for Claude, the Task call's `description` input, then
one is given; falling back to the tool's name when the child arrives before ` (<subagent_type>)` when one is given; falling back to `Task` when the
(or without) the parent call being seen. 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 ## Server layout
- `session/subagent.rs` -- the registry: `Subagents` (per session, in - `session/subagent.rs` -- the registry: `Subagents` (per session, in
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a `Shared`), `Subagent` (its `Transcript` behind a mutex plus a
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title, `broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an 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. `Arc<Subagents>` beside their `EventSink`; llama ignores it.
- `session/claude/translate.rs` -- routes child lines by parent id, holds - `session/claude/translate.rs` -- routes child lines by parent id, holds
one child `Translator` per subagent, remembers pending Task calls' one child `Translator` per subagent, remembers pending Task calls'
description/prompt/subagent_type. description/prompt/subagent_type.
- `session/codex/translate.rs` -- routes multiplexed app-server notifications
by thread id, remembers collaboration prompts, and translates activity
edges into the same registry lifecycle.
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1) - `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
subagents at once, each named "helper k". Each writes the prompt as its subagents at once, each named "helper k". Each writes the prompt as its
user message, streams a few words of text, runs one `Bash` tool call, then user message, streams a few words of text, runs one `Bash` tool call, then
finishes about three seconds after starting, and the parent's Task calls finishes about three seconds after starting, and the parent's Task calls
end when their subagent does. Three seconds so the running state can be end when their subagent does. Three seconds so the running state can be
seen on the phone. seen on the phone.
- `routes.rs` -- three routes, in the doc table. - `routes.rs` -- four routes, in the doc table.
## Wire shape ## Wire shape
@@ -110,11 +228,23 @@ GET /sessions same field on each row
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
POST /sessions/{id}/subagents/delete {subagents} -> 204; refused whole if one is running
``` ```
The delete is a batch rather than a `DELETE` per id for the reason the import
list's is: the phone deletes what a reader selected, and one request per row
means a batch can half-arrive, leaving the rows that were missed looking
exactly like rows nobody picked. Unlike an import delete it is local file
removal, so it is done by the time the reply is sent and there is no per-row
state to follow afterwards. What decides "running" is
`Subagents::list`'s own rule, shared through `routes::has_a_process` so the
list and the delete cannot disagree about it.
`status` is the transcript's last `Status` event, serialised like a session's `status` is the transcript's last `Status` event, serialised like a session's
(`running`, `exited`), except that a subagent whose session is not itself (`running`, `exited`), except that a subagent whose session is not itself
running cannot be running: the list answers `unknown` for that one. The 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. phone words these as *running*, *finished* and *unknown* on the subcard.
The count on `SessionInfo` is a directory listing, so the list stays cheap. The count on `SessionInfo` is a directory listing, so the list stays cheap.
@@ -122,19 +252,37 @@ The per-subagent status is only read when the list route is asked for.
## Phone ## Phone
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an - The subcards are ordered **still running first, then most recently
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to active** -- a display decision made on the phone (`subagentOrder`), over the
`Pointing.Up` -- collapsed by default. Expanding fetches server's stable oldest-first answer. Two keys rather than activity alone
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent, because a subagent that is thinking reports nothing meanwhile and would sink
indented inside the session card, the way dev-updater draws a project's below one that just finished.
components: title, then the status word and a relative time. The - **Holding a subcard selects it, and several at a time**, exactly as the
expansion state is per session id and survives a refresh of the list. import list works, with the selection bar drawn inside the panel rather than
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in at the bottom of the session: this selection belongs to the subagent list,
**read-only** form: the same transcript, paging, cache, selection, 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 images and status row, with the composer, the process button, the model
picker, the files button, the settings cog and the usage bar left out. picker, the files button, the settings cog and the usage bar left out.
The header shows the subagent's title with the session's title beneath The header shows the subagent's title with the session's title beneath it.
it. Back returns to the list. It is another layer over the still-composed session and its panel; Back
returns to the panel.
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the - Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
cache take a transcript address rather than a session id -- cache take a transcript address rather than a session id --
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a `sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
+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
@@ -11,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
@@ -21,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
@@ -31,41 +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()
* The session list, with a subagent's own transcript over it when [subagent] is set.
*
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
* itself the moment its own subagent's view was closed.
*/
data class Main(val subagent: SubagentTarget? = null) : Screen()
/** /**
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and * One session, with the file explorer or a subagent transcript over it when set.
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* is opened from the session list's card rather than from inside the session it belongs to.
*/
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
/**
* One session, with the file explorer over it when [files] is set.
* *
* The explorer is a layer on this screen rather than a screen of its own, so the session under * 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()
} }
@@ -99,7 +99,7 @@ fun AppRoot(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.Main()) } var screen by remember { mutableStateOf<Screen>(Screen.Main) }
// A notification tap this could not follow, and why. Null both before one is asked for and // A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message. // after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) } var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
@@ -114,7 +114,7 @@ fun AppRoot(
share = shareRequest share = shareRequest
// A session already open takes it. Otherwise the list is where the choice is made, // A session already open takes it. Otherwise the list is where the choice is made,
// whatever screen was showing: Spawn and Settings have nowhere to put a file. // whatever screen was showing: Spawn and Settings have nowhere to put a file.
if (screen !is Screen.Session) screen = Screen.Main() if (screen !is Screen.Session) screen = Screen.Main
} }
} }
@@ -141,7 +141,7 @@ fun AppRoot(
existing = null, existing = null,
onSaved = { saved -> onSaved = { saved ->
settings = saved settings = saved
screen = Screen.Main() screen = Screen.Main
}, },
onBack = null, onBack = null,
) )
@@ -154,7 +154,7 @@ fun AppRoot(
// shows, so it always refetches. // shows, so it always refetches.
val goToMain = { val goToMain = {
reloadToken++ reloadToken++
screen = Screen.Main() screen = Screen.Main
} }
if (screen !is Screen.Main) { if (screen !is Screen.Main) {
BackHandler(onBack = goToMain) BackHandler(onBack = goToMain)
@@ -196,44 +196,23 @@ 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,
reloadToken = reloadToken, reloadToken = reloadToken,
share = share, share = share,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onOpenSubagent = { summary, subagent ->
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
},
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onImported = { imported -> onImported = { imported ->
reloadToken++ reloadToken++
screen = Screen.Session(imported) screen = Screen.Session(imported)
}, },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
) )
// Its own back handler is registered after MainScreen's, so it is the one the
// platform asks first while a subagent is open -- the same rule the files
// explorer's handler follows over its session, below.
here.subagent?.let { target ->
BackHandler { screen = here.copy(subagent = null) }
// Its own opaque background: this screen was always the sole content under
// the theme's own Surface before, so it never had to paint one -- stacked over
// the list here, the space between its own cards let the list underneath show
// through without this. The same fix FilesScreen needed over its session.
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
key(target.summary.id, target.subagent.id) {
SessionScreen(
settings = current,
summary = target.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = target.subagent,
)
}
}
}
} }
is Screen.Session -> is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this // Keyed on the id, because a different session is a different screen rather than this
@@ -246,6 +225,78 @@ 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)
}
// Where the panel has asked the session screen to put the reader: the
// call a background task was started by. Held here rather than inside
// either, because the two are siblings -- the panel is the one being
// tapped and the transcript is the one that can travel.
var goTo by remember(here.summary.id) { mutableStateOf<CallSite?>(null) }
// The two panels this session can be pulled aside for: its subagents
// from the right, and the whole main screen from the left. Both are here
// rather than screens of their own for the same reason the explorer is --
// the session under them stays composed. The main panel exists only
// inside a session, which is what makes it unswipeable until one has been
// opened.
SidePanels(
left = { active, close ->
MainPanel(
settings = current,
sessionId = here.summary.id,
active = active,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
onClose = close,
onGone = goToMain,
)
},
// The whole width: it stands in for the screen Back would have shown,
// rather than sitting over the session the way the subagents do.
leftFraction = 1f,
right = { active, close ->
SubagentPanel(
settings = current,
summary = here.summary,
active = active,
backgroundTasks = backgroundTasks,
onClose = close,
onOpenSubagent = { screen = here.copy(subagent = it) },
// Closed with it: what the reader asked to see is under this
// panel, and a panel left open over the answer is the one
// thing the tap cannot have meant.
onOpenCall = {
goTo = it
close()
},
)
},
) {
CompositionLocalProvider(
LocalFileLinkHandler provides fileLinkHandler
) {
SessionScreen( SessionScreen(
settings = current, settings = current,
summary = here.summary, summary = here.summary,
@@ -253,7 +304,29 @@ 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 },
goTo = goTo,
onGoToTaken = { goTo = null },
) )
}
}
}
here.subagent?.let { subagent ->
BackHandler { screen = here.copy(subagent = null) }
Box(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
) {
key(subagent.id) {
SessionScreen(
settings = current,
summary = here.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = subagent,
)
}
}
}
// Its own back handler is registered after this screen's, so it is the one the // Its own back handler is registered after this screen's, so it is the one the
// platform asks first, and it steps back inside itself before closing. // platform asks first, and it steps back inside itself before closing.
here.files?.let { target -> here.files?.let { target ->
@@ -265,6 +338,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(
@@ -11,6 +11,30 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Whether a session can be sent a picture, as the server answers it.
*
* Three states rather than a switch, because for a local model the answer belongs to the server
* that loaded it: one still coming off disk genuinely has not said. [UNKNOWN] is offered -- a
* control withheld because nobody could ask is a photo button missing from a session that would
* have read the photo perfectly well, and the send path says so if the guess was wrong.
*/
enum class ImageSupport {
ACCEPTED,
REFUSED,
UNKNOWN,
}
/**
* What the server called it; anything else -- an older server, a newer word -- is not an answer.
*/
fun imageSupport(word: String): ImageSupport =
when (word) {
"accepted" -> ImageSupport.ACCEPTED
"refused" -> ImageSupport.REFUSED
else -> ImageSupport.UNKNOWN
}
/** /**
* Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension * Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension
* from the list it writes -- rather than a file kept under its own name. Mirrors the server's * from the list it writes -- rather than a file kept under its own name. Mirrors the server's
@@ -38,6 +38,11 @@ suspend fun uploadPickedImage(
* Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage] * Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage]
* and is shrunk; anything else goes whole, under the name the other app or the file chooser gave * and is shrunk; anything else goes whole, under the name the other app or the file chooser gave
* it, because the session is told that name rather than shown the bytes. * it, because the session is told that name rather than shown the bytes.
*
* A picture is refused here, before anything is read or sent, when [images] says this session's
* model cannot read one. Here rather than beside the photo button because this is where every way
* of attaching meets: the picker, the file chooser, and another app's share sheet -- and only the
* first of those has a button to disable.
*/ */
suspend fun uploadPicked( suspend fun uploadPicked(
context: Context, context: Context,
@@ -45,10 +50,16 @@ suspend fun uploadPicked(
sessionId: String, sessionId: String,
uri: Uri, uri: Uri,
maxEdge: Int?, maxEdge: Int?,
images: ImageSupport,
): String { ): String {
val resolver = context.contentResolver val resolver = context.contentResolver
val mime = resolver.getType(uri) val mime = resolver.getType(uri)
if (mime != null && mime.startsWith("image/")) { if (mime != null && mime.startsWith("image/")) {
if (images == ImageSupport.REFUSED) {
throw ApiException(
"this session's model can't read pictures, so that one wasn't attached"
)
}
return uploadPickedImage(context, settings, sessionId, uri, maxEdge) return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
} }
// Opened before the request starts, so a provider that refuses says so here and not from inside // Opened before the request starts, so a provider that refuses says so here and not from inside
@@ -0,0 +1,204 @@
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* The background work a session has going, above its subagents in the panel [SidePanels] slides
* over it from the right.
*
* Collapsed to its one-line count by default, the way everything else this app adds to a screen
* arrives: what a reader came to the panel for is the subagents, and a run of cards about work
* nobody asked after would push them off it. Expanding pushes them down instead of covering them,
* so the two are read together.
*
* 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,
onOpenCall: (CallSite) -> 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}" }) { task ->
BackgroundTaskCard(
task,
onOpen = task.call?.let { call -> { onOpenCall(call) } },
)
}
}
}
}
/**
* One background task: what it is doing, drawn as one line with its kind as the mark beside it.
*
* The kind used to be a second line under the words, which on a list of backgrounded commands was
* "background command" repeated down the panel -- and for a provider that names a task by a process
* id it was the *whole* card, so every row said the same two words. A mark carries the same
* difference in a width the text does not have to make room for, and it is the [Glyph]'s
* description that keeps the words for anybody who cannot see it.
*
* [onOpen] is where the call that started this is in the transcript, for the readers who tap it:
* null where the provider never said which call it was, or where that call is no longer in the
* transcript, and the card is then a statement rather than a control. The chevron is what says
* which of the two this is, since a card that quietly does nothing when pressed is worse than one
* that never invited the press.
*/
@Composable
private fun BackgroundTaskCard(task: BackgroundTaskSummary, onOpen: (() -> Unit)?) {
val look = backgroundTaskLook(task.kind)
OutlinedCard(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.then(
if (onOpen == null) Modifier
else
Modifier.clickable(
onClickLabel = "Show where this started",
onClick = onOpen,
)
)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Glyph(
look.glyph,
colour = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.semantics { contentDescription = look.words },
)
Spacer(Modifier.width(10.dp))
// The kind stands in as the words where the provider gave no description, rather than
// the id it named the task by: Codex reports a process number, which says nothing to
// the person reading and would look like a name somebody chose.
//
// Cut at its tail: what identifies a command is the program at its head, and the long
// ones are exactly the ones being read closely.
Text(
task.description ?: look.words,
style =
if (look.mono)
MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
else MaterialTheme.typography.bodyMedium,
color =
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
else LocalContentColor.current,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (onOpen != null) {
Spacer(Modifier.width(8.dp))
Chevron(Pointing.Right)
}
}
}
}
/** How one kind of background task is drawn: see [backgroundTaskLook]. */
private data class TaskLook(val glyph: String, val words: String, val mono: Boolean)
/**
* Everything a [BackgroundTaskSummary.kind] decides, answered by one `when`.
*
* One rather than three, which is the rule this screen already learned once with the status word
* and its colour: three `when`s over one set is two of them waiting to miss a member.
*
* A kind this build has not heard of takes the question mark and is named by what every one of them
* has in common. The nearest word or mark we do know -- a robot, a terminal -- would be this screen
* deciding what the server meant by a word it invented after this build shipped.
*/
private fun backgroundTaskLook(kind: String) =
when (kind) {
// A command is drawn in the face a command is drawn in everywhere else here.
"command" -> TaskLook(COMMAND_GLYPH, "background command", mono = true)
"agent" -> TaskLook(AGENT_GLYPH, "subagent", mono = false)
"workflow" -> TaskLook(WORKFLOW_GLYPH, "workflow", mono = false)
else -> TaskLook(UNKNOWN_GLYPH, "background task", mono = false)
}
/**
* The heading over one group in the panel, so neither list is a run of cards with no name.
*
* The same band as the background section's own heading row above, rather than a gap chosen to look
* right here: what separates a heading from the cards above it is that both headings sit in a row
* of one height.
*/
@Composable
fun PanelSectionHeading(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 48.dp)) {
Text(text, style = MaterialTheme.typography.titleMedium)
}
}
@@ -1,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)}"
}
@@ -40,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.
* *
@@ -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()
/** /**
@@ -127,6 +168,16 @@ sealed class SessionEvent {
*/ */
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent() data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/**
* Whether a picture can be sent to this session now, as the thing serving its model answered.
*
* Only a llama.cpp session says this, and it says it twice per model: unknown the moment the
* old one is left, then the loaded server's answer. It carries no row -- it is what the
* composer's photo button is drawn from, and a line in the transcript about a control is not
* something anybody asked after.
*/
data class Images(val images: ImageSupport) : SessionEvent()
/** /**
* What a turn cost, and how much the model was holding when it ended. * What a turn cost, and how much the model was holding when it ended.
* *
@@ -135,7 +186,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.
@@ -169,6 +238,8 @@ sealed class SessionEvent {
*/ */
data class LimitReached(val resetsAt: Double?) : SessionEvent() data class LimitReached(val resetsAt: Double?) : SessionEvent()
data class AuthenticationRequired(val message: String) : SessionEvent()
data class Error(val message: String) : SessionEvent() data class Error(val message: String) : SessionEvent()
/** /**
@@ -205,6 +276,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"),
@@ -252,19 +326,26 @@ fun parseSeqEvent(json: String): SeqEvent {
body.getString("text"), body.getString("text"),
if (body.has("turnStart")) body.getLong("turnStart") else null, if (body.has("turnStart")) body.getLong("turnStart") else null,
) )
"unreadable" -> SessionEvent.Unreadable(body.getString("kind"))
"taskNote" -> SessionEvent.RetiredTaskNote
"commandQueued" -> "commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text")) SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text")) "commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
"backgroundTasks" -> SessionEvent.BackgroundTasks(body.getInt("count"))
"status" -> SessionEvent.Status(body.getString("state")) "status" -> SessionEvent.Status(body.getString("state"))
"settings" -> "settings" ->
SessionEvent.Settings( SessionEvent.Settings(
model = body.optString("model").ifEmpty { null }, model = body.optString("model").ifEmpty { null },
permissionMode = body.optString("permissionMode").ifEmpty { null }, permissionMode = body.optString("permissionMode").ifEmpty { null },
) )
"images" -> SessionEvent.Images(imageSupport(body.optString("images")))
"contextWindow" -> SessionEvent.ContextWindow(body.getLong("tokens"))
"usageDelta" -> "usageDelta" ->
SessionEvent.UsageDelta( SessionEvent.UsageDelta(
body.getLong("tokens"), body.getLong("tokens"),
if (body.has("context")) body.getLong("context") else null, if (body.has("context")) body.getLong("context") else null,
if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null,
if (body.has("prefillMs")) body.getLong("prefillMs") else null,
) )
"compacted" -> "compacted" ->
SessionEvent.Compacted( SessionEvent.Compacted(
@@ -277,6 +358,8 @@ fun parseSeqEvent(json: String): SeqEvent {
SessionEvent.LimitReached( SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null if (body.has("resetsAt")) body.getDouble("resetsAt") else null
) )
"authenticationRequired" ->
SessionEvent.AuthenticationRequired(body.getString("message"))
"error" -> SessionEvent.Error(body.getString("message")) "error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type) else -> SessionEvent.Unknown(type)
} }
@@ -291,7 +374,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.
@@ -315,3 +412,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>,
@@ -349,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
@@ -48,11 +52,13 @@ fun MainScreen(
/** What another app shared in and no session has taken yet; see [ShareRequest]. */ /** What another app shared in and no session has taken yet; see [ShareRequest]. */
share: ShareRequest? = null, share: ShareRequest? = null,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit, onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
/** One machine's provider, opened from the machines tab. */
onProvider: (String, String) -> Unit,
/** A session the list has just deleted; see [SessionListScreen]. */
onDeleted: (String) -> Unit = {},
) { ) {
var tab by remember { mutableStateOf(MainTab.Sessions) } var tab by remember { mutableStateOf(MainTab.Sessions) }
var refreshToken by remember { mutableIntStateOf(0) } var refreshToken by remember { mutableIntStateOf(0) }
@@ -141,13 +147,13 @@ fun MainScreen(
settings = settings, settings = settings,
reloadToken = token, reloadToken = token,
onOpen = onOpen, onOpen = onOpen,
onOpenSubagent = onOpenSubagent,
onSpawn = onSpawn, onSpawn = onSpawn,
onDeleted = onDeleted,
) )
MainTab.Import -> MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported) ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.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,34 @@ val EDIT_GLYPH = glyph(0xF03EB)
*/ */
val SAVE_GLYPH = glyph(0xF0193) val SAVE_GLYPH = glyph(0xF0193)
/**
* `md-menu` -- the burger: three stacked rules, drawn as the handle a row is dragged by.
*
* The mark for "take hold of this and move it" rather than for a menu, which is what it means on a
* row that has one: three rules look like the rows of a list, and the only thing here that draws
* them is a list being rearranged. Nothing else in this app opens a menu from a burger, so the two
* senses cannot be confused.
*/
val DRAG_GLYPH = glyph(0xF035C)
/**
* `md-console_line` -- a shell prompt: a backgrounded command, in the panel beside the turn.
*
* The four marks here are one set, drawn by `backgroundTaskLook`: they exist because the kind of a
* background task used to be a word on a line of its own, which on a list of commands was the same
* two words down the whole panel. Each keeps its words as the description a screen reader is given.
*/
val COMMAND_GLYPH = glyph(0xF07B7)
/** `md-robot` -- a subagent: something running that is doing its own reasoning. */
val AGENT_GLYPH = glyph(0xF06A9)
/** `md-sitemap` -- a workflow: steps arranged by something other than the agent itself. */
val WORKFLOW_GLYPH = glyph(0xF04AA)
/** `md-help_circle_outline` -- a background task of a kind this build has not heard of. */
val UNKNOWN_GLYPH = glyph(0xF0625)
/** /**
* The size an icon draws at beside a line of text. * The size an icon draws at beside a line of text.
* *
@@ -165,7 +193,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,26 +1,28 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard 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
@@ -33,16 +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.semantics.contentDescription import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* The sessions tab: 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
@@ -53,39 +69,24 @@ fun SessionListScreen(
settings: ServerSettings, settings: ServerSettings,
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
/** A session this list has just deleted, for whoever is showing it elsewhere. */
onDeleted: (String) -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) } var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Which session cards are expanded to show their subagents, and what each expansion fetched. // Which rows the reader has picked out. Empty means selection mode is off, as on the import
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt // tab: a selection mode with nothing in it has no controls and no way out but Back.
// from var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
// whatever the server last said, and this belongs to the reader's own choice, which survives a
// refresh.
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
var subagentLoads by remember {
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
}
fun loadSubagents(sessionId: String) { // The sessions a delete has been confirmed for, or none. A list rather than one session,
subagentLoads = subagentLoads + (sessionId to LoadState.Loading) // because a selection is what the bar below acts on.
scope.launch { var confirmingDelete by remember { mutableStateOf<List<SessionSummary>>(emptyList()) }
subagentLoads =
subagentLoads + // Whether an answer is outstanding, which is a different question from whether there is
(sessionId to // anything to draw: see [refresh].
try { var reloading by remember { mutableStateOf(false) }
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
)
} catch (e: ApiException) {
LoadState.failed(e)
})
}
}
// Failures that belong to one session rather than to the list, keyed by its id and shown on its // Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused, // own card. The two scopes are decided by whether the server answered: it answered and refused,
@@ -94,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()) }
@@ -104,39 +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())
}
// A session gone from this answer cannot still be expanded, and an expanded one
// that is still here asks again -- its subagents may have changed since the
// last
// fetch.
val ids = loaded.value.map { it.id }.toSet()
expandedSessions = expandedSessions intersect ids
subagentLoads = subagentLoads.filterKeys { it in ids }
expandedSessions.forEach(::loadSubagents)
loaded loaded
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
reloading = false
}
}
/**
* Deletes every session in [targets], one after another.
*
* One at a time and in the order they are drawn: the server has no batch delete for sessions,
* and each one ends a process. Each row says what is happening to it from the moment the work
* is handed over, which is also when the selection goes -- a bar still naming sessions being
* deleted is a set nobody can act on.
*/
fun deleteChosen(targets: List<SessionSummary>, alsoDeleteForeign: Boolean) {
selected = emptySet()
// Marked here rather than after the request returns: a row has to say something is
// happening to it from the moment it is asked for.
deleting = deleting + targets.map { it.id }
deleteErrors = deleteErrors - targets.map { it.id }.toSet()
scope.launch {
for (session in targets) {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the session
// exactly as it was, and its transcript with it.
transcriptCache.session(TranscriptAddress(session.id)).purge()
}
// Only this row, and only what changed. Refetching the list instead put every
// other session back through loading and handed the reader an empty screen, to
// report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState = LoadState.Loaded(loaded.value.filterNot { it.id == session.id })
}
onDeleted(session.id)
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the session it
// refused about is exactly as it was.
deleteErrors = deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
} }
} }
LaunchedEffect(reloadToken) { refresh() } LaunchedEffect(reloadToken) { refresh() }
val rows = rememberLazyListState()
val reorder =
rememberReorder(
listState = rows,
onMove = { from, to ->
// Moved here and now, because the row is under a finger: waiting for the server to
// agree would drag the handle away from the card it is on. What the server thinks
// is asked for when the finger comes up, and a refusal puts the list back.
val loaded = listState
if (loaded is LoadState.Loaded) {
val moved = loaded.value.toMutableList()
moved.add(to, moved.removeAt(from))
listState = LoadState.Loaded(moved)
}
},
onSettled = {
val loaded = listState
if (loaded is LoadState.Loaded) {
val order = loaded.value.map { it.id }
scope.launch {
try {
withContext(Dispatchers.IO) { reorderSessions(settings, order) }
orderError = null
} catch (e: ApiException) {
orderError = e.message ?: "The new order couldn't be saved"
// The screen must not go on showing an arrangement nothing kept, so
// the server's own order comes back -- which is also the only way to
// see what it does think.
refresh()
}
}
}
},
)
// Back leaves selection mode rather than the tab, which is the level it is one step above.
// Nested inside MainScreen's own handler, so it wins while there is a selection.
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
// can still be scrolled to while it is up.
var barHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
// What the bar covers *now*: its measurement is kept while it is away, but nothing is
// reserved for a bar that is not up.
val covered = if (selected.isEmpty()) 0.dp else barHeight
// The spawn button floats over the list, so the list ends above it -- measured, for the
// reason the bar is. Without this the last row sat under the button, which was survivable
// while every part of a row did the same thing and is not now that corner is a handle.
var buttonHeight by remember { mutableStateOf(0.dp) }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
orderError?.let { message ->
// The server's own words, unprefixed, the way every other failure is shown.
Text(
message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
}
when (val state = listState) { when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is already a whole // The message as Api.kt wrote it, with nothing added: it is already a whole
@@ -155,31 +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
expanded = session.id in expandedSessions, // than disabled outside one: this is not a capability being
subagents = subagentLoads[session.id], // withheld, it is a mode the list is not in.
onToggleSubagents = { reorder = reorder.takeIf { selected.isNotEmpty() },
if (session.id in expandedSessions) { onClick = {
expandedSessions = expandedSessions - session.id // In selection mode a tap is a selection, so the reader is
} else { // never one mis-tap away from opening a session they were only
expandedSessions = expandedSessions + session.id // picking rows for.
loadSubagents(session.id) if (selected.isEmpty()) onOpen(session)
} else
selected =
if (session.id in selected) selected - session.id
else selected + session.id
}, },
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) }, onLongPress = { selected = selected + session.id },
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
@@ -188,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),
) )
@@ -267,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(TranscriptAddress(session.id)).purge()
}
// Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the
// reader an empty screen, to report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState =
LoadState.Loaded(
loaded.value.filterNot { it.id == session.id }
)
}
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the
// session it refused about is exactly as it was.
deleteErrors =
deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
} }
) { ) {
// Coloured by consequence: this takes something away, and does so wherever it // Coloured by consequence: this takes something away, and does so wherever it
@@ -307,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(
@@ -327,28 +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,
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
expanded: Boolean,
/** What the expander's own fetch answered, or null before it has been asked. */
subagents: LoadState<List<SubagentSummary>>?,
onToggleSubagents: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
) { ) {
val held = reorder?.held == session.id
BusyItem(label = if (deleting) "deleting" else null) { BusyItem(label = if (deleting) "deleting" else null) {
Card( Card(
// 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(),
@@ -359,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) },
) )
@@ -391,119 +589,32 @@ private fun SessionCard(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
// Nothing at all for a card with no subagents: a disabled expander here would be
// noise on every ordinary session's card. Its own row at the bottom rather than
// beside the title or the machine line, so opening it never displaces text that was
// already on screen -- see UI_RULES on a control not displacing the text beside it.
if (session.subagents > 0) {
Spacer(Modifier.height(8.dp))
// The platform's minimum touch height, not the chevron's own ten or so dp:
// at the chevron's height a tap meant for it landed on the first subcard
// beneath and opened a subagent instead.
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(enabled = !deleting, onClick = onToggleSubagents)
.semantics {
contentDescription =
if (expanded) "Collapse subagents" else "Expand subagents"
},
) {
Chevron(if (expanded) Pointing.Up else Pointing.Down)
} }
if (expanded) { // Inside the card, so what it moves is the thing it is drawn on. Nothing is held
Spacer(Modifier.height(4.dp)) // open for it outside selection mode: the row is then the row it always was.
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (reorder != null) {
when (subagents) { ReorderHandle(
null, reorder,
is LoadState.Loading -> session.id,
CircularProgressIndicator( // Dimmed with the rest of the row while something is happening to it, since
modifier = Modifier.width(20.dp).height(20.dp), // a row on its way out is not one to rearrange -- see [BusyItem], whose
strokeWidth = 2.dp, // 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),
) )
is LoadState.Error ->
// Said here rather than left silent: a fetch that failed and an
// expander that simply found nothing must not look the same --
// see UI_RULES on designing the unknown state first.
Text(
subagents.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded ->
subagents.value.forEach { subagent ->
SubagentCard(
subagent,
onClick = { onOpenSubagent(subagent) },
)
}
}
}
}
} }
} }
} }
} }
} }
/**
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
* filled one, so the nesting reads as one step rather than as another session.
*/
@Composable
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped.
*/
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@Composable @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
@@ -44,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
@@ -61,15 +63,36 @@ fun SessionSettingsDialog(
title: String, title: String,
onRenamed: (String) -> Unit, onRenamed: (String) -> Unit,
/** /**
* How hard the model thinks, as the session reports it, or null for the CLI's own default. * How hard the model thinks, or null for the CLI's own default.
* *
* Taken from the row this dialog was opened over rather than fetched, because unlike the * Owned by the screen behind this rather than held here, like [title]: this dialog is what
* notification switch there is nothing else that changes it: the level is this app's to set and * changes it, and a level kept only for as long as the dialog is open is the old one again the
* the server does not resolve it into something else. * 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?, effort: String?,
onEffortChanged: (String?) -> Unit,
/** Whether a level does anything here; the row is left out entirely where it does not. */ /** Whether a level does anything here; the row is left out entirely where it does not. */
takesEffort: Boolean, takesEffort: Boolean,
/**
* The settings that are one of a list -- the model and the permission mode.
*
* Owned by the screen behind this, like [title] and [effort]: it is what asked the machine what
* the provider offers. Whichever of them this one has no answer for is not in the list, and
* draws no row.
*/
choices: List<SessionChoice>,
/**
* The settings this session's provider takes, and what they are set to.
*
* Declared by the server rather than listed here -- see [ProviderParamFields]. Empty for a
* provider with none, which draws no section at all.
*/
paramSpecs: List<ParamSpec>,
params: Map<String, String>,
onParamsChanged: (Map<String, String>) -> Unit,
/** /**
* What this phone is holding of the conversation, or null while that is being measured -- see * What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it. * the Reload row below, which is what would discard it.
@@ -85,7 +108,6 @@ fun SessionSettingsDialog(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) } var name by remember(sessionId) { mutableStateOf(title) }
var level by remember(sessionId) { mutableStateOf(effort) }
var effortError by remember { mutableStateOf<String?>(null) } var effortError by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) } var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
@@ -165,14 +187,14 @@ fun SessionSettingsDialog(
* control that stays where it was put after a refusal is stating something untrue. * control that stays where it was put after a refusal is stating something untrue.
*/ */
fun setEffort(chosen: String?) { fun setEffort(chosen: String?) {
val was = level val was = effort
level = chosen onEffortChanged(chosen)
effortError = null effortError = null
scope.launch { scope.launch {
try { try {
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) } withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
} catch (e: ApiException) { } catch (e: ApiException) {
level = was onEffortChanged(was)
effortError = e.message effortError = e.message
} }
} }
@@ -410,6 +432,20 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
choices.forEach { choice ->
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(choice.label, modifier = Modifier.weight(1f))
PickerButton(
current = choice.current,
options = choice.options,
onPick = choice.onPick,
)
}
}
// Left out rather than disabled, the one place this dialog does that: a disabled // Left out rather than disabled, the one place this dialog does that: a disabled
// control teaches what the thing can do, and a llama session cannot do this at all // control teaches what the thing can do, and a llama session cannot do this at all
// -- the row would be teaching something false about it. // -- the row would be teaching something false about it.
@@ -421,7 +457,7 @@ fun SessionSettingsDialog(
) { ) {
Text("Thinking", modifier = Modifier.weight(1f)) Text("Thinking", modifier = Modifier.weight(1f))
PickerButton( PickerButton(
current = level ?: DEFAULT_EFFORT, current = effort ?: DEFAULT_EFFORT,
// The level the CLI picks for itself is in the list as well as in the // The level the CLI picks for itself is in the list as well as in the
// button, so leaving a level is not a one-way trip -- the same // button, so leaving a level is not a one-way trip -- the same
// correction the model picker carries. // correction the model picker carries.
@@ -448,6 +484,23 @@ fun SessionSettingsDialog(
) )
} }
} }
if (paramSpecs.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
Text(
"Model settings",
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
// Edited here and saved by the screen behind this, which is what makes
// typing in a text field affordable: the save is debounced, and a dialog
// dismissed mid-edit would take an unsaved value with it.
ProviderParamFields(
specs = paramSpecs,
values = params,
onChange = onParamsChanged,
warnAboutRestart = true,
)
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -521,6 +574,19 @@ fun SessionSettingsDialog(
) )
} }
/**
* One session setting that is a choice from a list, as this dialog draws it.
*
* A shape rather than a pair of parameters each, because a provider may offer either of them, both
* or neither, and they are otherwise the same control.
*/
data class SessionChoice(
val label: String,
val current: String,
val options: List<String>,
val onPick: (String) -> Unit,
)
/** /**
* When the server will next look, as a local time. * When the server will next look, as a local time.
* *
@@ -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
@@ -83,7 +85,7 @@ class UsageFeed(
return when (val state = snapshots) { return when (val state = snapshots) {
is LoadState.Loading -> SessionUsage.Waiting is LoadState.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message) is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, session.setup, provider) is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model)
} }
} }
} }
@@ -125,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
@@ -142,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
@@ -159,16 +161,10 @@ 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 session that meters nothing: a row saying "unknown" there would report // Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// a problem about a setup somebody chose, on every screen, forever. // a problem about a machine somebody chose, on every screen, forever.
// //
// And nothing while the first fetch is out, which is a different silence. A request in flight // And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this // is not a state to report -- and the session that meters nothing is exactly the one this
@@ -189,22 +185,15 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
// Both handled above, before the row exists at all. // Both handled above, before the row exists at all.
SessionUsage.NotMetered, SessionUsage.NotMetered,
SessionUsage.Waiting -> Unit SessionUsage.Waiting -> Unit
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}") 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),
@@ -215,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) {
@@ -226,31 +265,40 @@ 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 meter's snapshot, out of every machine's: [setup]'s row for [provider]. * 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 * Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one * account and, while a test has one set, an echo session's invented one -- and a snapshot is one
@@ -260,14 +308,60 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the * None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered. * machine having no quota rather than the question going unanswered.
*/ */
fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage { fun usageFor(
snapshots: List<UsageSnapshot>,
machine: String,
provider: String,
model: String?,
): SessionUsage {
// No snapshot at all means the backend never asked, which it only does where there is nothing // No snapshot at all means the backend never asked, which it only does where there is nothing
// to ask about. That is a different answer from having asked and failed. // to ask about. That is a different answer from having asked and failed.
val pools = usageSnapshotsFor(snapshots, machine, provider)
if (pools.isEmpty()) return SessionUsage.NotMetered
val mine = val mine =
snapshots.firstOrNull { it.setup == setup && it.provider == provider } usagePoolFor(pools, model)
?: return SessionUsage.NotMetered ?: 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,19 +48,22 @@ 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 // Null until the server has been asked, and null again if it answers "no level chosen" -- the
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer // two are told apart by [defaultsAsked], because a picker that shows a level before the answer
// arrives is one you can spawn at without having chosen it. // arrives is one you can spawn at without having chosen it.
@@ -70,17 +73,12 @@ fun SpawnScreen(
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// form worth keeping, and that one leaves nothing to fill in. // form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) } var spawnError by remember { mutableStateOf<String?>(null) }
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate // Whatever the chosen provider says it takes, by key. Empty until something is typed: an
// from the setups: a Claude session needs none, so failing to list them must not stop the // absent key means the server's own default, which is what every field's placeholder says.
// screen rendering. Refetched when the machine changes, because a model is a file on one var params by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// machine -- see [fetchSetupModels].
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") }
var temperature by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Separate from the setups fetch below and deliberately not fatal: failing to learn the // 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" // default must leave a screen you can still spawn from, so the picker stays on "default"
// and says so rather than the whole form refusing to draw. // and says so rather than the whole form refusing to draw.
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } } runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
@@ -88,9 +86,9 @@ fun SpawnScreen(
defaultsAsked = true 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) {
@@ -112,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()
@@ -124,62 +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 }
// Whichever machine is chosen now, asked again when that changes. The old machine's list val current = machine?.providers?.firstOrNull { it.name == providerName }
// is dropped first rather than left on screen: a file name from another machine looks // Coding CLIs take a working directory, model, permission mode and thinking level. Keying
// exactly like one from this one. // the extra fields on the kind rather than the provider name keeps a second installation
LaunchedEffect(setup?.id) {
models = emptyList()
modelKey = null
val id = setup?.id ?: return@LaunchedEffect
models =
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
.getOrDefault(emptyList())
}
val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and permission modes; keying the
// extra fields on the kind rather than the provider name keeps a second Claude provider
// from needing anything here. // from needing anything here.
val isClaude = current?.kind == "claude_cli" val isClaude = current?.kind == "claude_cli"
val isCodex = current?.kind == "codex_cli"
val isCodingCli = isClaude || isCodex
val isLlama = current?.kind == "llama_cpp" val isLlama = current?.kind == "llama_cpp"
// Echo is the only kind with nothing to choose between.
val offersModels = isCodingCli || isLlama
// Where a session's tools act, which is the only thing a working directory decides.
val takesCwd = isCodingCli || isLlama
// Whichever machine and provider are chosen now, asked again when either changes. The
// previous answer is dropped first rather than left on screen: a model name from another
// machine looks exactly like one from this one.
LaunchedEffect(machine?.id, current?.name) {
model = ""
// A key from the previous provider would be a setting this one does not have, drawn
// by no control and sent at the spawn anyway.
params = emptyMap()
providerModels = emptyList()
providerModelsError = null
permissionMode = current?.defaultPermissionMode.orEmpty()
// Every kind that offers models at all, not only the coding CLIs: a llama provider
// answers with the GGUFs on the machine it runs on, through the same call. One
// question with one answer is what keeps the picker free of a branch on the kind.
if (machine == null || current == null || !offersModels) {
providerModelsLoading = false
return@LaunchedEffect
}
providerModelsLoading = true
try {
providerModels =
withContext(Dispatchers.IO) {
fetchProviderModels(settings, machine.id, current.name)
}
} catch (e: ApiException) {
providerModelsError = e.message
} finally {
providerModelsLoading = false
}
}
// The machine first, because it decides what can be run at all. // The machine first, because it decides what can be run at all.
ChipGroup( ChipGroup(
label = "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 },
) )
@@ -195,59 +219,59 @@ fun SpawnScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (isLlama) { if (offersModels) {
// A llama session names one of the models on the machine it will run on, so the when {
// choice is that list rather than free text -- a name that is not on that machine's providerModelsLoading ->
// disk is a session that cannot start.
if (models.isEmpty()) {
Text( Text(
"No models on ${setup?.name ?: "this machine"}. The Models screen downloads " + "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.", "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 },
@@ -256,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 },
@@ -266,15 +303,21 @@ 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)) Spacer(Modifier.height(16.dp))
}
if (isCodingCli) {
// Says what it does to *later* spawns as well, because it does: the level chosen here // Says what it does to *later* spawns as well, because it does: the level chosen here
// is stored as the default, which is the whole way that default is set. A picker that // is stored as the default, which is the whole way that default is set. A picker that
// quietly changed a global would be the same control with the fact left out. // quietly changed a global would be the same control with the fact left out.
@@ -307,37 +350,25 @@ fun SpawnScreen(
// an intent about new sessions in general, so a spawn that then // an intent about new sessions in general, so a spawn that then
// fails must not also lose the choice. Non-fatal for the same // fails must not also lose the choice. Non-fatal for the same
// reason the fetch above is -- the session is what was asked for. // reason the fetch above is -- the session is what was asked for.
if (isClaude) { if (isCodingCli) {
runCatching { setDefaultEffort(settings, effort) } runCatching { setDefaultEffort(settings, effort) }
} }
spawnSession( spawnSession(
settings, settings,
// The id, not the label: labels are editable and the server // The id, not the label: labels are editable and the server
// resolves by id. Non-null here, since `chosen` came from // resolves by id. Non-null here, since `chosen` came from
// `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 },
effort = effort.takeIf { isClaude }, // Already only the keys somebody set: a field left blank
// Sent only when set, so blank means "whatever llama.cpp does // removes its key rather than sending an empty value, so
// by default" rather than a zero. // "blank" reaches the server as "your default".
params = params = params,
buildMap {
if (isLlama) {
contextSize
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("contextSize", it) }
temperature
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("temperature", it) }
}
},
) )
} }
onSpawned(spawned) onSpawned(spawned)
@@ -347,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,344 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* What a session has running beside the turn you are reading: its background tasks, then its
* subagents, in the panel [SidePanels] slides over it from the right.
*
* [active] is whether the panel is being looked at: the lists are fetched then rather than on
* composition, since the panel is composed for every session whether or not anybody opens it.
*
* [onOpenCall] takes the reader to where a background task was started, in the transcript under
* this panel -- so the panel is closed with it, which is the caller's to do.
*
* [backgroundTasks] is the live count from the session's own event stream, and is what the
* background list is refetched against: a card for work that has since finished is a stale
* measurement drawn as a current one, which is the one thing a list of what is running now must not
* do.
*
* Both lists are items of one lazy column rather than two stacked scrollers, so expanding the
* background section pushes the subagents down without either being able to run off the panel.
*/
@Composable
fun SubagentPanel(
settings: ServerSettings,
summary: SessionSummary,
active: Boolean,
backgroundTasks: Int,
onClose: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
onOpenCall: (CallSite) -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
var rows by
remember(summary.id) { mutableStateOf<LoadState<List<SubagentSummary>>>(LoadState.Loading) }
var selected by remember(summary.id) { mutableStateOf(setOf<String>()) }
var deleting by remember(summary.id) { mutableStateOf(setOf<String>()) }
var deleteError by remember(summary.id) { mutableStateOf<String?>(null) }
var confirming by remember(summary.id) { mutableStateOf<List<SubagentSummary>?>(null) }
var refreshToken by remember(summary.id) { mutableIntStateOf(0) }
var background by
remember(summary.id) {
mutableStateOf<LoadState<List<BackgroundTaskSummary>?>>(LoadState.Loading)
}
var backgroundExpanded by remember(summary.id) { mutableStateOf(false) }
LaunchedEffect(active, refreshToken) {
if (!active) return@LaunchedEffect
rows = LoadState.Loading
rows =
try {
val fetched = withContext(Dispatchers.IO) { fetchSubagents(settings, summary.id) }
selected = selected intersect fetched.mapTo(mutableSetOf()) { it.id }
LoadState.Loaded(fetched)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// No reset to Loading on a refetch: the spinner belongs to the first fetch, and one flashed
// over the list at every start and end would blink precisely when something happened.
LaunchedEffect(active, backgroundTasks, refreshToken) {
if (!active || backgroundTasks == 0) return@LaunchedEffect
background =
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchBackgroundTasks(settings, summary.id) }
)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder)
Column(Modifier.fillMaxSize()) {
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++ },
onOpenCall = onOpenCall,
)
item(key = "subagents-heading") { PanelSectionHeading("Subagents") }
when (val state = rows) {
is LoadState.Loading ->
item(key = "subagents-loading") {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
is LoadState.Error ->
item(key = "subagents-error") {
Column {
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = { refreshToken++ }) { Text("Try again") }
}
}
is LoadState.Loaded ->
if (ordered.isNullOrEmpty()) {
item(key = "subagents-empty") {
Text(
"No subagents in this session.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
uniqueItems(ordered, key = { it.id }) { subagent ->
SubagentCard(
subagent = subagent,
selected = subagent.id in selected,
selecting = selected.isNotEmpty(),
deleting = subagent.id in deleting,
onClick = { onOpenSubagent(subagent) },
onSelect = {
selected =
if (subagent.id in selected) selected - subagent.id
else selected + subagent.id
},
)
}
}
}
}
if (selected.isNotEmpty()) {
val picked = ordered.orEmpty().filter { it.id in selected }
SubagentSelectionBar(
picked = picked,
onDelete = { confirming = picked },
modifier = Modifier.padding(horizontal = 16.dp),
)
}
deleteError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
confirming?.let { picked ->
AlertDialog(
onDismissRequest = { confirming = null },
title = {
Text(
if (picked.size == 1) "Delete this subagent?"
else "Delete ${picked.size} subagents?"
)
},
text = {
Text(
(if (picked.size == 1) "\"${picked.first().title}\"\n\n" else "") +
"A subagent's transcript is the only record of what it did: the session " +
"that started it kept just the Task call. Nothing else has a copy, so " +
"this can't be undone. The session itself is untouched."
)
},
confirmButton = {
TextButton(
onClick = {
confirming = null
selected = emptySet()
val ids = picked.map { it.id }
val gone = ids.toSet()
deleting += gone
deleteError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSubagents(settings, summary.id, ids)
gone.forEach {
transcriptCache
.session(TranscriptAddress(summary.id, it))
.purge()
}
}
val loaded = rows
if (loaded is LoadState.Loaded) {
rows =
LoadState.Loaded(loaded.value.filterNot { it.id in gone })
}
} catch (e: ApiException) {
deleteError = e.message ?: "Delete failed"
} finally {
deleting -= gone
}
}
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
)
}
}
private fun subagentOrder(rows: List<SubagentSummary>): List<SubagentSummary> =
rows.sortedWith(
compareByDescending<SubagentSummary> { it.status == "running" }
.thenByDescending { it.lastActivity }
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SubagentCard(
subagent: SubagentSummary,
selected: Boolean,
selecting: Boolean,
deleting: Boolean,
onClick: () -> Unit,
onSelect: () -> Unit,
) {
BusyItem(label = if (deleting) "deleting" else null) {
OutlinedCard(
colors =
if (selected)
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
else CardDefaults.outlinedCardColors(),
modifier =
Modifier.fillMaxWidth()
.combinedClickable(
enabled = !deleting,
onClick = { if (selecting) onSelect() else onClick() },
onLongClick = onSelect,
),
) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun SubagentSelectionBar(
picked: List<SubagentSummary>,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
val running = picked.count { it.status == "running" }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().heightIn(min = 48.dp),
) {
Text(
if (running == 0) "${picked.size} selected" else "$running still running",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDelete, enabled = running == 0) {
Text(
"Delete",
color =
if (running == 0) MaterialTheme.colorScheme.error
else LocalContentColor.current,
)
}
}
}
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@@ -134,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.
* *
@@ -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
@@ -231,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 =
@@ -261,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
@@ -278,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))
} }
@@ -364,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,
@@ -382,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
@@ -456,7 +600,13 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// nothing it belongs above. // nothing it belongs above.
is SessionEvent.MessageDropped -> items is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items is SessionEvent.Settings -> items
// Neither carries a row: both are about what the session can do rather than about anything
// said in it, and the composer is where they are drawn.
is SessionEvent.Images -> items
is SessionEvent.BackgroundTasks -> items
is SessionEvent.Status -> settleReply(items, event.state) is SessionEvent.Status -> settleReply(items, event.state)
is SessionEvent.AuthenticationRequired ->
items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image -> is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of its own when there is // Under the call that produced it when there is one, and a row of its own when there is
@@ -475,8 +625,27 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
is SessionEvent.Compacted -> is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens) items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]") is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// 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
} }
/** /**
@@ -487,9 +656,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") }
}
}
} }
} }
} }
@@ -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,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")
}
}
@@ -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")
}
}
+5
View File
@@ -45,6 +45,11 @@ GLYPHS=(
U+F0193 # md-content_save U+F0193 # md-content_save
U+F0224 # md-file_outline U+F0224 # md-file_outline
U+F201 # fa-line_chart -- Font Awesome's, asked for by name U+F201 # fa-line_chart -- Font Awesome's, asked for by name
U+F035C # md-menu -- the burger, as a row's drag handle
U+F07B7 # md-console_line -- a backgrounded command
U+F06A9 # md-robot -- a subagent
U+F04AA # md-sitemap -- a workflow
U+F0625 # md-help_circle_outline -- a background task of a kind this build does not know
) )
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
+2 -2
View File
@@ -120,10 +120,10 @@ TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; } api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
echo "==> Importing" echo "==> Importing"
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",
+458 -62
View File
@@ -21,6 +21,8 @@ use serde::{Deserialize, Serialize};
use wg_app_link::format; use wg_app_link::format;
use crate::session::driver::Images;
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct Config { pub struct Config {
@@ -28,7 +30,15 @@ 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. /// What a new session's thinking level is when nothing chose one.
/// ///
@@ -52,8 +62,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.
@@ -62,19 +72,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 {
@@ -88,6 +98,46 @@ 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>,
}
/// 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 { impl ProviderConfig {
@@ -100,7 +150,7 @@ impl ProviderConfig {
} }
} }
/// How to reach a setup that isn't this machine, with the system `ssh` client /// 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.
@@ -156,6 +206,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 {
@@ -176,7 +228,22 @@ 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,
}
}
/// What a session of this kind makes of an image attached to a message.
///
/// The provider's own answer, which for every CLI here is that it takes
/// them. llama.cpp is [`Images::Unknown`] because the question is not the
/// provider's to answer: a local model reads images only if its file has a
/// projector loaded beside it, and the server that loaded it is the only
/// thing that knows -- `LlamaDriver::images` gives the live answer once
/// there is one, and this is what a session with no process yet reports.
pub fn images(self) -> Images {
match self {
DriverKind::Echo | DriverKind::ClaudeCli | DriverKind::CodexCli => Images::Accepted,
DriverKind::LlamaCpp => Images::Unknown,
} }
} }
@@ -199,6 +266,7 @@ impl DriverKind {
pub fn usage_provider(self) -> Option<&'static str> { pub fn usage_provider(self) -> Option<&'static str> {
match self { match self {
Self::ClaudeCli => Some(crate::usage::CLAUDE), Self::ClaudeCli => Some(crate::usage::CLAUDE),
Self::CodexCli => Some(crate::usage::CODEX),
Self::Echo => Some(crate::usage::ECHO), Self::Echo => Some(crate::usage::ECHO),
Self::LlamaCpp => None, Self::LlamaCpp => None,
} }
@@ -207,12 +275,13 @@ impl DriverKind {
/// The executable a provider of this kind runs when it names 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 /// Here rather than at each spawn site because it is not only the spawn
/// that runs it: `usage` runs the Claude CLI too, to have it refresh its /// that runs it: `usage` runs provider CLIs too, so a default that
/// own OAuth token, and a default that disagreed with the driver's would /// disagreed with the driver's would ask the wrong binary on a machine
/// ask the wrong binary on a machine with two installs. /// with two installs.
pub fn default_program(self) -> &'static str { pub fn default_program(self) -> &'static str {
match self { match self {
Self::ClaudeCli => "claude", Self::ClaudeCli => "claude",
Self::CodexCli => "codex",
Self::LlamaCpp => "llama-server", Self::LlamaCpp => "llama-server",
// Echo is translated in-process; nothing is spawned for it. // Echo is translated in-process; nothing is spawned for it.
Self::Echo => "echo", Self::Echo => "echo",
@@ -222,18 +291,26 @@ impl DriverKind {
/// 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::Echo | Self::LlamaCpp => false, Self::CodexCli => Some("Codex"),
Self::Echo | Self::LlamaCpp => None,
} }
} }
@@ -242,20 +319,267 @@ impl DriverKind {
/// ///
/// Reported from here rather than decided on the phone, and asked of the /// Reported from here rather than decided on the phone, and asked of the
/// *kind* rather than branched on: the alternative is the session-type /// *kind* rather than branched on: the alternative is the session-type
/// `if` this app does not have anywhere else. `--effort` is the Claude /// `if` this app does not have anywhere else. Coding CLIs take an effort
/// CLI's; a llama session's sampling is `params`, and echo does not think. /// 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 /// 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 /// choosing a level stops the process -- so on a session that cannot use
/// one it is a button whose only effect is the cost. /// one it is a button whose only effect is the cost.
pub fn takes_effort(self) -> bool { pub fn takes_effort(self) -> bool {
match self { match self {
Self::ClaudeCli => true, 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: "mmproj",
label: "Vision projector",
// Found rather than asked for, because a repository publishing a
// vision model publishes the projector beside it -- so this is for the
// two cases finding it cannot cover: a repository that published more
// than one, and a model whose projector somebody would rather not
// spend the memory on.
unset: "the mmproj file beside the model -- a file name, or \"off\" for none",
kind: ParamKind::Text,
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")]
pub struct TokenEntry { pub struct TokenEntry {
@@ -270,12 +594,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,
@@ -283,20 +610,17 @@ 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, passed straight to `--effort`. A string for /// How hard the model thinks. A string for the same reason
/// the same reason `permission_mode` is: the CLI owns which levels exist. /// `permission_mode` is: the CLI owns which levels exist.
/// ///
/// Unlike the model and the mode, there is no control request that changes /// This is settled at launch and `None` means whatever the CLI's own
/// one -- checked against 2.1.258, whose only two are `set_model` and /// default is. That is a state the phone has to be able to *choose*, not
/// `set_permission_mode` -- so this is settled at launch and `None` means /// just start in, which is why it is an option rather than a level with a
/// whatever the CLI's own default is. That is a state the phone has to be /// default written here.
/// 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<String>, pub effort: Option<String>,
/// Settings the driver interprets, chosen at spawn. /// Settings the driver interprets, chosen at spawn.
@@ -390,17 +714,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.
@@ -409,15 +733,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.
@@ -426,10 +750,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,
} }
@@ -444,13 +768,20 @@ 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,
} }
} }
pub fn load(path: &Path) -> Result<Self> { pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) { match std::fs::read_to_string(path) {
Ok(text) => format::parse(&text) Ok(text) => format::parse(&text)
.with_context(|| format!("{} is not valid config RON", path.display())), .with_context(|| format!("{} is not valid config RON", path.display()))
.map(|mut config| {
Self::adopt_mcp_defaults(&mut config);
config
}),
// A first run has no config -- the normal starting state; a token is // A first run has no config -- the normal starting state; a token is
// generated and saved on that first start. // generated and saved on that first start.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
@@ -461,6 +792,29 @@ impl Config {
} }
} }
/// Gives a provider written before `mcp_servers` existed the defaults a
/// probe would give it now.
///
/// A migration, and a temporary one: the field arrived on 2026-09-19 and a
/// machine discovered before then has an empty list, which reads on screen
/// exactly like a machine somebody chose to give no MCP servers -- so a
/// llama session silently had no web search and nothing said why. Pressing
/// Rediscover fixes it, which is not something a person can be expected to
/// know. Delete this once every config here has been through it.
///
/// Empty rather than absent is the condition, because there is no way to
/// remove one from the phone: nothing can have chosen the empty list yet.
fn adopt_mcp_defaults(&mut self) {
for provider in self
.machines
.iter_mut()
.flat_map(|machine| machine.providers.iter_mut())
.filter(|provider| provider.mcp_servers.is_empty())
{
provider.mcp_servers = crate::machines::mcp_defaults(provider.kind);
}
}
/// Writes the config, owner-readable only. /// Writes the config, owner-readable only.
/// ///
/// The token hashes here are verifiers rather than secrets, but the file /// The token hashes here are verifiers rather than secrets, but the file
@@ -504,11 +858,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 {
@@ -516,7 +870,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 {
@@ -524,9 +878,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 {
@@ -542,13 +899,16 @@ 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()), 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,
@@ -568,14 +928,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")
@@ -583,15 +943,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
@@ -613,6 +979,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.
@@ -624,7 +1017,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,
@@ -643,6 +1036,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!(
+54 -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
@@ -176,6 +176,34 @@ pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
}) })
} }
/// The absolute path `path` names on that machine, with a leading `~` expanded
/// *there*.
///
/// What [`list`] answers as its `path`, asked on its own: a caller that has to
/// hand a directory to something with no shell in front of it needs the
/// resolved form and nothing else. `llama-server` is the one such caller --
/// its tools take a working directory as a request header and `chdir` to it
/// literally, so the `~` that every other path in this server carries through
/// to the far side's shell arrived there as a directory called `~`, and every
/// tool that uses one failed with "failed to spawn process".
///
/// Blocking because a driver's launch is, and this is a question for the
/// machine that will serve the session rather than for this one: a remote
/// `~` is the remote home, and expanding it here would name a directory on
/// the wrong machine -- which is also why the answer is not cached anywhere
/// but on the driver that asked.
pub fn resolve_blocking(transport: &Transport, path: &str) -> Result<String> {
let script = format!("{PATH_PRELUDE}cd -- \"$p\" && pwd -P");
let out = transport.capture_blocking(&launch(script, path, None))?;
// Only the newline `pwd` ends with: a directory name may legitimately end
// in a space, and trimming whitespace would rename it.
let resolved = out.trim_end_matches('\n');
if resolved.is_empty() {
anyhow::bail!("the machine did not say what {path} resolves to");
}
Ok(resolved.to_string())
}
/// The `find` output above, as rows. A record without all five fields is /// The `find` output above, as rows. A record without all five fields is
/// dropped rather than guessed at: it can only come from a `find` that printed /// dropped rather than guessed at: it can only come from a `find` that printed
/// something else, and half a row is worse than no row. /// something else, and half a row is worse than no row.
@@ -436,6 +464,30 @@ mod tests {
assert_eq!(names, ["binary.bin", "hello.txt", "it's a file", "sub"]); assert_eq!(names, ["binary.bin", "hello.txt", "it's a file", "sub"]);
} }
/// The regression `resolve_blocking` exists for: a working directory typed
/// as `~/…` reaches `llama-server` as a header it `chdir`s to, so it has to
/// arrive absolute or every tool that uses one fails to spawn.
#[test]
fn a_tilde_resolves_to_that_machine_s_home() {
let Some(home) = std::env::home_dir() else {
return;
};
let resolved = resolve_blocking(&Transport::Here, "~").unwrap();
assert!(resolved.starts_with('/'), "{resolved}");
assert_eq!(
std::fs::canonicalize(&resolved).unwrap(),
std::fs::canonicalize(&home).unwrap(),
);
let dir = tree();
// An absolute path is answered as itself, resolved.
let full = dir.path().to_string_lossy().into_owned();
assert_eq!(
resolve_blocking(&Transport::Here, &full).unwrap(),
std::fs::canonicalize(&full).unwrap().to_string_lossy(),
);
assert!(resolve_blocking(&Transport::Here, &at(&dir, "nope")).is_err());
}
#[tokio::test] #[tokio::test]
async fn a_missing_directory_fails_with_the_machine_s_own_message() { async fn a_missing_directory_fails_with_the_machine_s_own_message() {
let dir = tree(); let dir = tree();
+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);
}
}
}
}
+340
View File
@@ -0,0 +1,340 @@
//! Finding out what a machine can run, rather than being told.
//!
//! The phone adds a machine by giving connection details; this asks the machine
//! 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
//! a command from the phone.** If it did, the enrolled token could introduce
//! 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
//! phone keyboard, and a machine that has moved its binaries answers correctly
//! on the next probe.
//!
//! The cost is that a program somewhere unusual is invisible. The escape hatch
//! is editing `config.ron` on the backend, which is exactly the authority the
//! phone is not being given.
use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{Value, json};
use crate::config::{DriverKind, ProviderConfig};
use crate::session::transport::{Launch, Transport};
/// What is looked for, and what finding it makes. Extending this is how a new
/// driver becomes discoverable -- one row, not a branch anywhere. The name is
/// what the provider gets called, so it is what the phone shows and what a
/// session stores.
const PROBES: &[(&str, &str, DriverKind)] = &[
("claude-cli", "claude", DriverKind::ClaudeCli),
("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
/// screen, not a restriction -- the field stays free text.
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
/// Asks `transport`'s machine which of [`PROBES`] it has.
///
/// One round trip rather than one per program: over ssh each would be a separate
/// connection and handshake. `command -v` is POSIX and a shell builtin, so it
/// works whatever is installed -- and `|| true` keeps a missing program from
/// ending the loop, since the caller wants the whole answer.
pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect();
let script = format!(
"for p in {}; do command -v \"$p\" || true; done",
wanted.join(" ")
);
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
let found = transport.capture(&launch).await.map_err(explain)?;
let mut providers = Vec::new();
// Echo runs inside this server, so it exists exactly where this server does
// and nowhere else. Offering it on a remote machine would be a choice that
// changes nothing.
if matches!(transport, Transport::Here) {
providers.push(crate::config::Config::echo_provider());
}
for (name, binary, kind) in PROBES {
let path = found
.lines()
.map(str::trim)
.find(|line| line.rsplit('/').next() == Some(*binary));
let Some(path) = path else {
continue;
};
providers.push(ProviderConfig {
name: (*name).to_string(),
kind: *kind,
// The resolved path rather than the bare name: PATH under a
// non-interactive ssh session is not the one a person sees when they
// log in, so "it is on my PATH" is not enough.
command: Some(path.to_string()),
models: match kind {
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
_ => 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)
}
/// 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.
pub 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 mut found = crate::models::on_machine(transport, &dir).await?;
// A vision model's projector is a file beside it rather than a model,
// and the session that reads pictures is the one on the model: offered
// here it is a chip that starts a server which cannot load it. It is
// still in the machine's own model list, which is where a file on a
// disk is managed and deleted.
found.retain(|model| {
!crate::session::llama::is_projector(model.key.rsplit('/').next().unwrap_or_default())
});
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.
///
/// ssh's messages are written for someone at a terminal on the backend, which is
/// exactly who is not reading this one. Host key verification is the case that
/// matters: **every** machine fails it the first time, so without this, adding a
/// machine from the phone looks broken rather than unfinished.
///
/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` stays
/// at its default, so a first connection is a decision somebody makes on the
/// backend with the key in front of them.
fn explain(err: anyhow::Error) -> anyhow::Error {
let message = format!("{err:#}");
if message.contains("Host key verification failed") {
return anyhow::anyhow!(
"{message} This machine has not been connected to before, so its key is not \
trusted yet. Ssh to it once from the backend -- that is where the decision to \
trust a key belongs -- and try again.",
);
}
if message.contains("Permission denied") {
return anyhow::anyhow!(
"{message} The key named here has to be authorized on that machine, and the path \
is read on the backend rather than on the phone.",
);
}
err
}
/// A short, stable, filename-safe id derived from a label. Derived once when a
/// 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.
pub fn id_from(label: &str) -> String {
let slug: String = label
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let slug = slug.trim_matches('-').replace("--", "-");
if slug.is_empty() {
crate::session::random_hex()
} else {
slug.chars().take(32).collect()
}
}
/// Normalises what a phone keyboard produced: trims, and reads a field left
/// blank as absent rather than as an empty answer.
///
/// It deliberately does **not** touch a leading `~`. A path is stored as it was
/// typed, because `~` and `/home/someone` are not two spellings of one path --
/// the second is a snapshot of where the first pointed, and it is the snapshot
/// that breaks when an account is renamed or the value is read on another
/// machine. Expansion belongs where the path is used, against the machine it
/// belongs to: `ssh::quote_path` and `files::PATH_PRELUDE` for a remote one,
/// `ssh::expand_home` for one on this machine.
pub fn tidy(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
/// Runs a launch to completion and returns its stdout as text.
///
/// 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
/// 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.
impl Transport {
pub async fn capture(&self, launch: &Launch) -> Result<String> {
let captured = self
.capture_with_input(launch, super::session::transport::Input::None)
.await?;
Ok(String::from_utf8_lossy(&captured.ok()?).into_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A path survives the boundary unchanged, tilde included -- the one thing
/// `tidy` must not do is decide where `~` is.
#[test]
fn a_typed_path_is_stored_as_typed() {
assert_eq!(
tidy(" ~/repos/ai-app-2 ").as_deref(),
Some("~/repos/ai-app-2")
);
assert_eq!(tidy("/etc/hosts").as_deref(), Some("/etc/hosts"));
assert_eq!(tidy(" "), None);
}
#[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"
);
}
}
+25 -16
View File
@@ -14,12 +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 resume;
mod routes; mod routes;
mod session; mod session;
mod setups;
mod ssh; mod ssh;
mod usage; mod usage;
@@ -42,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.
@@ -143,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.
@@ -173,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()))?
@@ -188,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);
} }
} }
@@ -263,11 +264,12 @@ 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.
// The fixture is the manager's, because that is where the `/usage` command // The fixture is the manager's, because that is where the `/usage` command
// that sets it is typed; the monitor is what serves it. // that sets it is typed; the monitor is what serves it.
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture())); 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 // 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 // switched to auto-resume waits out its account's usage limit and picks
@@ -279,8 +281,14 @@ async fn main() -> Result<()> {
// 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,
@@ -322,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(())
+470 -488
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);
}
}
}
+14 -11
View File
@@ -48,7 +48,7 @@ const AT_LEAST: f64 = 60.0;
/// How long after the limit was hit to stop waiting. /// How long after the limit was hit to stop waiting.
/// ///
/// Something has to bound it, or a machine that can never be asked -- an /// Something has to bound it, or a machine that can never be asked -- an
/// unplugged laptop, a setup somebody edited away -- is retried for ever with /// 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 /// 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. /// 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; const GIVE_UP: f64 = 24.0 * 60.0 * 60.0;
@@ -123,7 +123,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
Step::Send => match manager.resume_now(&owed.session_id) { Step::Send => match manager.resume_now(&owed.session_id) {
Ok(message) => tracing::info!( Ok(message) => tracing::info!(
"the limit on {} has lifted; sent \"{message}\" to {}", "the limit on {} has lifted; sent \"{message}\" to {}",
owed.setup, owed.machine,
owed.session_id owed.session_id
), ),
Err(err) => { Err(err) => {
@@ -144,7 +144,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
// to read on a phone. // to read on a phone.
let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) { let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) {
Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(), Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(),
_ => format!("{} could not be asked for a day", owed.setup), _ => format!("{} could not be asked for a day", owed.machine),
}; };
if let Err(err) = manager.abandon_resume(&owed.session_id, &why) { if let Err(err) = manager.abandon_resume(&owed.session_id, &why) {
tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id); tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id);
@@ -164,18 +164,18 @@ async fn snapshot_for(
manager: &SessionManager, manager: &SessionManager,
owed: &OwedResume, owed: &OwedResume,
) -> Option<UsageSnapshot> { ) -> Option<UsageSnapshot> {
let setups: Vec<_> = manager let machines: Vec<_> = manager
.setups() .machines()
.into_iter() .into_iter()
.filter(|setup| setup.id == owed.setup) .filter(|machine| machine.id == owed.machine)
.collect(); .collect();
if setups.is_empty() { if machines.is_empty() {
return None; return None;
} }
let provider = owed.provider; let provider = owed.provider;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
monitor monitor
.snapshots(&setups) .snapshots(&machines)
.into_iter() .into_iter()
.find(|snapshot| snapshot.provider == provider) .find(|snapshot| snapshot.provider == provider)
}) })
@@ -258,7 +258,7 @@ mod tests {
fn owed(since: f64) -> OwedResume { fn owed(since: f64) -> OwedResume {
OwedResume { OwedResume {
session_id: "s1".to_string(), session_id: "s1".to_string(),
setup: "local".to_string(), machine: "local".to_string(),
provider: crate::usage::CLAUDE, provider: crate::usage::CLAUDE,
scheduled: ScheduledResume { at: since, since }, scheduled: ScheduledResume { at: since, since },
} }
@@ -267,8 +267,10 @@ mod tests {
fn snapshot(state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot { fn snapshot(state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
UsageSnapshot { UsageSnapshot {
provider: crate::usage::CLAUDE.to_string(), provider: crate::usage::CLAUDE.to_string(),
setup: "local".to_string(), machine: "local".to_string(),
setup_name: "this machine".to_string(), machine_name: "this machine".to_string(),
limit_id: None,
limit_name: None,
state, state,
windows, windows,
fetched_at: 0.0, fetched_at: 0.0,
@@ -280,6 +282,7 @@ mod tests {
kind: "session".to_string(), kind: "session".to_string(),
label: "5-hour window".to_string(), label: "5-hour window".to_string(),
percent, percent,
duration_minutes: Some(300),
resets_at: resets_at.map(str::to_string), resets_at: resets_at.map(str::to_string),
active: true, active: true,
} }
+823 -238
View File
File diff suppressed because it is too large. Load diff
+250 -70
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,7 +56,9 @@ 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::subagent::Subagents;
use super::transport::{Launch, Streams, Transport}; use super::transport::{Launch, Streams, Transport};
@@ -215,6 +216,7 @@ impl ClaudeDriver {
session_dir: &Path, session_dir: &Path,
sink: EventSink, sink: EventSink,
subagents: Arc<Subagents>, subagents: Arc<Subagents>,
initial_status: SessionStatus,
) -> Result<Self> { ) -> Result<Self> {
let state = Arc::new(Mutex::new(Translator::new( let state = Arc::new(Mutex::new(Translator::new(
session_dir.to_path_buf(), session_dir.to_path_buf(),
@@ -262,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
@@ -321,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
@@ -393,9 +406,9 @@ 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.program(); let program = provider.program();
let launch = Launch::new(program, args, meta.cwd.as_deref()); let launch = Launch::new(program, args, meta.cwd.as_deref());
@@ -499,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() {
@@ -518,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
@@ -542,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`.
@@ -803,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,
@@ -868,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();
@@ -938,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
@@ -1045,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)
@@ -1099,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
@@ -1106,14 +1150,7 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
/// one this server would have written, so a crafted id cannot name a file /// one this server would have written, so a crafted id cannot name a file
/// outside the session. /// outside the session.
fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> { fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> {
if !id let path = super::driver::attachment_path(session_dir, id)?;
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|| id.contains("..")
{
anyhow::bail!("invalid attachment id");
}
let path = session_dir.join("attachments").join(id);
// A file copied to the session's own machine is named where it landed there // A file copied to the session's own machine is named where it landed there
// -- `routes::upload_attachment` writes that down beside it -- because the // -- `routes::upload_attachment` writes that down beside it -- because the
// path has to be one the CLI can open, not one this server can. // path has to be one the CLI can open, not one this server can.
@@ -1572,4 +1609,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
+438 -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
@@ -22,6 +89,52 @@ pub type ImageRef = String;
/// `crate::media::media_type_for`. /// `crate::media::media_type_for`.
pub type AttachmentRef = String; pub type AttachmentRef = String;
/// Whether a session can be sent an image.
///
/// Asked before one is attached rather than when it is sent, because that is
/// the only moment at which the answer is worth anything: a photo refused
/// after it has been picked, shrunk and uploaded over the tunnel is a refusal
/// that cost everything sending it would have.
///
/// Three states and not a boolean, for the reason
/// [`crate::session::llama`] has to have them: a local model's answer is the
/// loaded server's to give, so a session whose model is still coming off disk
/// -- or has never been started -- genuinely does not know yet. "We could not
/// find out" drawn as "no" is a control that is never offered on a model that
/// reads images perfectly well.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Images {
/// The model reads them, so attaching one is offered.
Accepted,
/// It does not, and one sent anyway is refused where it arrives.
Refused,
/// Nobody has been able to ask. Offered, because the alternative is
/// withholding the control on every session that has not started yet.
Unknown,
}
/// Where an uploaded attachment sits in a session's directory.
///
/// Refused rather than resolved when the id is not one this server would have
/// written: the id reaches here from a phone and from transcript lines, and a
/// `..` in one would name a file outside the session. Every driver that opens
/// an attachment goes through this, so there is one answer to what an id may
/// hold.
pub(in crate::session) fn attachment_path(
session_dir: &std::path::Path,
id: &str,
) -> anyhow::Result<std::path::PathBuf> {
if id.contains("..")
|| !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
anyhow::bail!("invalid attachment id");
}
Ok(session_dir.join("attachments").join(id))
}
/// One choice offered in answer to a [`Event::Question`]. More than a label /// One choice offered in answer to a [`Event::Question`]. More than a label
/// because the reader is deciding rather than confirming: what an option /// because the reader is deciding rather than confirming: what an option
/// means, and what picking it would produce, are what decide it. /// means, and what picking it would produce, are what decide it.
@@ -135,6 +248,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 +359,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 +411,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 +437,38 @@ 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>,
}, },
/// Whether a picture can be sent here, as the thing serving the model
/// answered -- see [`Images`].
///
/// Its own event rather than a field on [`Event::Settings`] because it is
/// not something anybody set: it is measured, twice per model, and by a
/// driver rather than asked for by a phone. Emitted by llama.cpp alone,
/// where a model change can take the answer away -- so the phone withdraws
/// the control the moment the model that could read pictures is left,
/// rather than at whatever later point the session row is fetched again.
Images {
images: Images,
},
/// 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 +485,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.
/// ///
@@ -321,11 +576,40 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
resets_at: Option<f64>, 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
@@ -338,6 +622,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:
@@ -408,6 +711,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.
@@ -441,13 +786,61 @@ 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. What
/// `GET /sessions/{id}/background` answers is this resolved against the
/// transcript -- see [`BackgroundTaskView`](crate::session::BackgroundTaskView);
/// 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. Where [`call`](Self::call) is known
/// the transcript answers it instead, from the call's own arguments; see
/// [`LiveSession::background_tasks`](crate::session::LiveSession::background_tasks).
#[derive(Debug, Clone, PartialEq, Eq)]
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,
pub description: Option<String>,
pub kind: BackgroundTaskKind,
/// The id of the tool call that started it, where the provider says
/// which. `None` is a provider that does not, or one whose account of
/// the start was never seen -- an adopted process mid-task.
pub call: Option<String>,
}
/// 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
@@ -455,6 +848,17 @@ pub trait Driver: Send + Sync {
/// the transcript, so a driver that never sends it drops the message from /// the transcript, so a driver that never sends it drops the message from
/// the conversation entirely. /// the conversation entirely.
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>); fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>);
/// What this session makes of an image *now*, where that is not simply a
/// property of the provider -- `None` leaves the answer to
/// [`crate::config::DriverKind::images`], which is the whole answer for a
/// CLI that takes them whatever it is talking to.
///
/// Overridden by llama.cpp alone, and it has to be: which model a session
/// is on is changeable while it runs, and whether that model reads images
/// is the loaded server's answer rather than the provider's.
fn images(&self) -> Option<Images> {
None
}
/// Takes back a message that is still waiting, named by the id its /// Takes back a message that is still waiting, named by the id its
/// [`Event::MessageQueued`] carried. /// [`Event::MessageQueued`] carried.
/// ///
@@ -493,6 +897,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
@@ -557,6 +971,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
@@ -594,6 +1025,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)
@@ -633,6 +1066,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)
+241 -15
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
@@ -52,6 +54,15 @@
//! "helper k", its prompt recorded as its own first user message: a //! "helper k", its prompt recorded as its own first user message: a
//! streamed reply, one Bash call, then it finishes about three seconds //! streamed reply, one Bash call, then it finishes about three seconds
//! later, the same lifecycle a real Task call has -- see `SUBAGENTS.md`. //! 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
@@ -64,7 +75,8 @@ 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; use super::subagent::Subagents;
@@ -113,6 +125,9 @@ pub struct EchoDriver {
/// 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 /// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
/// test rig for the same registry the claude driver routes real Task /// test rig for the same registry the claude driver routes real Task
/// calls into. /// calls into.
@@ -430,18 +445,125 @@ impl EchoDriver {
}), }),
}); });
subagents.start(&id, &title, Some(&prompt)); subagents.start(&id, &title, Some(&prompt));
helpers.push((id, sink.clone(), Arc::clone(&subagents))); helpers.push((k, id, title, sink.clone(), Arc::clone(&subagents)));
} }
for (id, sink, subagents) in helpers { // How many are still to report, so the last one to finish
tokio::spawn(run_helper(id, sink, subagents)); // 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 { let _ = sink.send(Event::Status {
state: SessionStatus::Idle, state: SessionStatus::Waiting,
}); });
}); });
return; 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(),
// Deliberately unsaid, though this driver knows it: the
// command is on the call above, and leaving it to be
// resolved from there is what makes `/background` exercise
// the path a real provider's process id takes.
description: None,
kind: BackgroundTaskKind::Command,
call: Some(id.clone()),
});
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.
@@ -510,10 +632,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()
@@ -532,6 +659,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.
@@ -548,6 +676,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))
}); });
@@ -608,9 +742,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(),
@@ -641,7 +772,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"),
@@ -701,6 +832,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 {
@@ -720,10 +865,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:
@@ -732,6 +898,14 @@ 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();
}); });
@@ -747,6 +921,7 @@ impl EchoDriver {
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,
@@ -878,7 +1053,18 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
/// its `Running` state can be seen on the phone before it finishes. The /// 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 /// parent's own Task call for it ends at the same moment, the same way a
/// real Task's `tool_result` ends it. /// real Task's `tool_result` ends it.
async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) { /// 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(); let start = tokio::time::Instant::now();
for word in "Working on it now.".split_inclusive(' ') { for word in "Working on it now.".split_inclusive(' ') {
subagents.record( subagents.record(
@@ -906,16 +1092,52 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
output: "helper done".to_string(), output: "helper done".to_string(),
}, },
); );
let target = Duration::from_secs(3); // 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(); let elapsed = start.elapsed();
if elapsed < target { if elapsed < target {
tokio::time::sleep(target - elapsed).await; 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); subagents.finish(&id);
let _ = sink.send(Event::ToolEnd { let _ = sink.send(Event::ToolEnd {
id, id,
output: "subagent finished".to_string(), 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
@@ -1010,6 +1232,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".
-902
View File
@@ -1,902 +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. That is the second half of what a transport is -- "run this"
//! plus "reach this port" -- and it is what lets a session run on another
//! machine: [`Transport::reserve_port`] hands back a port the server binds
//! *there* and one that reaches it *here*, and the ssh connection carrying the
//! command carries the tunnel between them. The far `llama-server` binds
//! loopback only, so a model is never served to that machine's network.
//!
//! **The model file is the far machine's, not this one's.** A remote setup
//! names its own models directory (`SshConfig::models_dir`, defaulting to where
//! this backend keeps its downloads), and the file is looked for *there* -- so
//! a session naming a model that machine does not have says so, instead of
//! starting a server that will never load one. Downloading to another machine
//! is not built; the model gets there however anything else does.
//!
//! **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.
#[allow(clippy::too_many_arguments)]
pub fn launch(
meta: &SessionConfig,
provider: &ProviderConfig,
transport: &Transport,
models_dir: &Path,
transcript: &Path,
session_dir: &Path,
sink: EventSink,
// llama.cpp has no notion of a Task call, so this is accepted only
// to keep one shape across every driver's launch -- see
// `SUBAGENTS.md`'s "Server layout".
_subagents: Arc<super::subagent::Subagents>,
) -> Result<Self> {
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_on(transport, 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,
));
}
// Where it listens on its own machine, and where that is reached
// from here -- the same number when that machine is this one.
let forward = transport
.reserve_port()
.context("finding a port for llama-server")?;
let mut args: Vec<String> = vec![
"-m".into(),
path.clone(),
// Loopback there, whichever machine there is: what reaches it
// from outside that machine is the ssh tunnel and nothing
// else.
"--host".into(),
"127.0.0.1".into(),
"--port".into(),
forward.there.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.program();
let launch = Launch::new(program, args, meta.cwd.as_deref()).reaching(forward);
// 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:{} there, \
reached at 127.0.0.1:{} here, as pid {pid}",
meta.id,
transport.describe(),
forward.there,
forward.here,
);
// 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;
});
// The *near* port, because that is the one anything reaching this
// server has to dial -- including a later run of this backend,
// which adopts the record without knowing which machine the server
// is on. For a remote session 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 server 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(session_dir, &record);
Ok(Self::attached(
format!("http://127.0.0.1:{}", forward.here),
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, &session_dir) {
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)
}
/// The model file's path **on the machine that will serve it**, confirmed to be
/// there.
///
/// One function rather than a local check and hope for the other case: the same
/// question has to be asked of two filesystems. The remote answer is measured
/// for the reason the local one is -- a missing file otherwise becomes a
/// `llama-server` that starts, fails to load, and reports as a session that
/// never became ready, which reads as the machine being slow.
///
/// One blocking round trip on a remote spawn, which is what the spawn is
/// already paying to start ssh. The alternative is a path built here from a `~`
/// this machine cannot expand.
fn model_on(transport: &Transport, models_dir: &Path, key: &str) -> Result<String> {
let Transport::Ssh { name, .. } = transport else {
return Ok(model_path(models_dir, key)?.to_string_lossy().into_owned());
};
// The same directory the spawn screen listed for this machine, and one
// function for the same reason: a list from one place and a load from
// another is a model that appears and then fails.
let dir = crate::models::dir_on(transport, models_dir);
// Checked here rather than in the script: `..` in a key would walk out of
// the models directory on a machine this server can start processes on,
// and the phone is where the key comes from.
for part in key.split('/') {
if part.is_empty() || part == "." || part == ".." {
bail!("\"{key}\" is not a model key this can resolve");
}
}
let path = format!("{}/{key}", dir.trim_end_matches('/'));
// `$HOME` on the far side, which is the only machine that knows what it is,
// and the resolved path printed back so the launch hands `llama-server`
// something absolute. "Not there" is answered rather than failed, because a
// machine that could not be asked at all has to say so in its own words --
// it would otherwise arrive as this same sentence about a missing model.
let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \
[ -f \"$p\" ] && printf 'at\\t%s\\n' \"$p\" || printf 'missing\\n'"
.to_string();
let launch = Launch::new(
"sh",
vec!["-c".to_string(), script, "sh".to_string(), path.clone()],
None,
);
let answer = transport
.capture_blocking(&launch)
.with_context(|| format!("couldn't ask {name} where its models are"))?;
match answer.trim().split_once('\t') {
Some(("at", resolved)) => Ok(resolved.to_string()),
_ => bail!(
"{name} has no model at {path}. A llama.cpp session serves the file from the \
machine it runs on, so the model has to be on {name} -- what this backend has \
downloaded is somewhere else."
),
}
}
/// Polls until the server says it is ready, or gives up.
///
/// Watches the process as well as the port, because the two failures need
/// different words and one of them is common: a model that will not load,
/// a port already taken on the far machine, a `llama-server` too old for
/// a flag. All of those exit within a second and none of them will ever
/// answer `/health`, so waiting out the timeout turns a server that said
/// exactly what was wrong into "gave up after 300s".
fn wait_until_ready(endpoint: &str, session_dir: &Path) -> 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(());
}
// `None` is the session having been stopped or deleted while this
// waited, which is nobody's fault and still not worth waiting on.
match process::recorded(session_dir) {
Some((_, process::Liveness::Alive | process::Liveness::Unknown)) => {}
Some((_, process::Liveness::Dead)) | None => {
bail!("it exited before it answered.{}", log_tail(session_dir));
}
}
if std::time::Instant::now() > deadline {
bail!(
"gave up after {}s.{}",
READY_TIMEOUT.as_secs(),
log_tail(session_dir)
);
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
/// The end of `llama-server`'s own 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 session it
/// is the only half, since nobody reading the phone can open a file on
/// that machine. Bounded, because this ends up in an event a phone draws.
fn log_tail(session_dir: &Path) -> String {
let Ok(text) = std::fs::read_to_string(session_dir.join(SERVER_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(" / ")
)
}
/// How much of that log to carry into a message somebody reads on a phone.
const LOG_TAIL_LINES: usize = 6;
/// 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
+930
View File
@@ -0,0 +1,930 @@
//! 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());
}
// What makes a model able to read pictures, and the one flag here that is
// found rather than defaulted: the projector is a second file published
// beside the weights, so a model that has one is loaded with it unless
// this model's settings name another or turn it off.
if let Some(projector) = projector(found, settings) {
lines.push(format!("mmproj = {projector}"));
}
lines.join("\n")
}
/// Which projector this model is loaded with: what its settings say, else
/// whatever was found beside it, and nothing for `"off"`.
///
/// A setting naming a bare file name means one in the model's own directory,
/// since that is where the alternatives to the file found there are; anything
/// with a `/` in it is taken as the path it is, absolute or not -- the serving
/// machine resolves it, and this side does not know its working directory.
fn projector(found: &Model, settings: &BTreeMap<String, String>) -> Option<String> {
let chosen = settings.get("mmproj").map(|value| value.trim());
match chosen {
Some("off") => None,
Some("") => found.mmproj.clone(),
Some(name) if name.contains('/') => Some(name.to_string()),
Some(name) => {
let dir = found.path.rsplit_once('/').map_or("", |(dir, _)| dir);
Some(format!("{dir}/{name}"))
}
None => found.mmproj.clone(),
}
}
/// 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 {
mmproj: None,
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"));
}
/// The projector found beside a model is loaded with it; the setting names
/// another where a repository published several, or turns it off.
#[test]
fn a_vision_model_is_loaded_with_its_projector() {
let found = Model {
path: "/models/repo/a.gguf".to_string(),
mtp: false,
mmproj: Some("/models/repo/mmproj-F16.gguf".to_string()),
};
let line = |settings: &[(&str, &str)]| {
section(&found, &self::settings(settings))
.lines()
.find_map(|line| line.strip_prefix("mmproj = "))
.map(str::to_string)
};
assert_eq!(line(&[]), Some("/models/repo/mmproj-F16.gguf".to_string()));
assert_eq!(
line(&[("mmproj", " ")]),
Some("/models/repo/mmproj-F16.gguf".to_string())
);
assert_eq!(line(&[("mmproj", "off")]), None);
// A bare name is one of the model's own neighbours; anything with a
// separator in it is the path it says it is.
assert_eq!(
line(&[("mmproj", "mmproj-F32.gguf")]),
Some("/models/repo/mmproj-F32.gguf".to_string()),
);
assert_eq!(
line(&[("mmproj", "/elsewhere/p.gguf")]),
Some("/elsewhere/p.gguf".to_string()),
);
// A model with none, and nothing asked for, loads without one.
assert!(
!section(
&Model {
mmproj: None,
..found.clone()
},
&settings(&[])
)
.contains("mmproj")
);
}
#[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);
}
}
+408
View File
@@ -0,0 +1,408 @@
//! What a llama session can do besides talk, and who runs it.
//!
//! Two sources, one list. The machine's `llama-server` runs a set of its own
//! -- reading, searching, editing, a shell -- and publishes them at
//! `GET /tools` in the shape a model is given, with `POST /tools` to run one.
//! That set belongs to the server rather than to any one session, so **which
//! of them a session offers is a [`Chosen`] applied at each request**: a
//! filter rather than a flag, which is what keeps one session's choice off
//! every other session sharing that server -- and what makes changing it take
//! effect on the next message rather than on a reload.
//! Anything else comes from an MCP server this backend is connected to (see
//! [`super::mcp`]). Both arrive here as a definition to offer and a way to
//! call, and nothing downstream of [`Tools::execute`] knows which a tool was.
//!
//! **The built-in tools run where the model does, and that is the point.** A
//! session on another machine edits that machine's files, because that is the
//! machine `llama-server` is on -- the same rule the model file already
//! follows. MCP tools run here instead, which is right for the opposite
//! reason: a web search wants the machine with a route out, not the one with
//! the GPU.
//!
//! **A tool's failure is a result, not an error.** A missing file, a command
//! that exited non-zero, a search that found nothing: all of those are things
//! the model has to read and act on, so they come back as the tool's output.
//! [`Tools::execute`] returns `Err` only when the tool could not be reached at
//! all, which is a fact about this server rather than about the work.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use serde_json::{Value, json};
use super::mcp::McpServer;
/// How long one tool call may take.
///
/// This is the shell tool's budget as much as anything: a build, a test run,
/// a `find` over a large tree. Bounded because it blocks the turn, and a
/// session stuck behind a command that will never finish cannot even be told
/// to stop.
const EXECUTE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// The tools one session has, and how to run each of them.
pub struct Tools {
/// The `llama-server` these belong to. Replaced when the session's model
/// changes, because that is a different server on a different port.
endpoint: String,
/// What the model is given, in the order it is offered: the server's own
/// tools first, then each MCP server's. Named as well, because which of
/// them a session offers is decided per request -- see [`offered`](Self::offered).
definitions: Vec<(String, Value)>,
/// The server's tools, and whether each is run relative to a working
/// directory. Only the ones that say so are sent one -- a tool that
/// ignores it would still have its cache keyed on it.
server: HashMap<String, bool>,
/// Connected MCP servers, each of which knows its own tools by the
/// prefixed names they were offered under.
mcp: Vec<Arc<Mutex<McpServer>>>,
}
impl Tools {
/// Asks a ready `llama-server` what it offers and adds what the MCP
/// servers offered. Everything it has, rather than what one session wants:
/// the choosing is [`Chosen`]'s, per request.
///
/// A session with no built-in tools -- or none at all -- is a perfectly
/// good session, so nothing here treats "no tools" as a failure. What *is*
/// a failure is not being able to ask at all, because that is the same
/// server the conversation is about to go to.
pub fn discover(endpoint: &str, mcp: Vec<Arc<Mutex<McpServer>>>) -> Result<Self> {
let mut response = ureq::get(format!("{endpoint}/tools"))
// A server started with no `--tools` answers **403** here, not an
// empty list -- the route is off rather than empty. Read as a
// failure that was a session which never started, for the one
// setting whose whole purpose is to have no tools.
.config()
.http_status_as_error(false)
.build()
.call()
.context("asking llama-server which tools it has")?;
let catalog: Vec<Value> = if response.status().is_success() {
response
.body_mut()
.read_json()
.context("reading llama-server's tool list")?
} else {
Vec::new()
};
let mut definitions = Vec::new();
let mut server = HashMap::new();
for entry in &catalog {
let Some(name) = entry
.pointer("/definition/function/name")
.and_then(Value::as_str)
else {
continue;
};
let Some(definition) = entry.get("definition") else {
continue;
};
server.insert(
name.to_string(),
entry
.get("uses_cwd")
.and_then(Value::as_bool)
.unwrap_or(false),
);
definitions.push((name.to_string(), definition.clone()));
}
for connected in &mcp {
for tool in connected.lock().unwrap().tools() {
definitions.push((tool.qualified.clone(), tool.definition.clone()));
}
}
Ok(Self {
endpoint: endpoint.to_string(),
definitions,
server,
mcp,
})
}
/// What goes in the request's `tools`, or `None` when there is nothing to
/// offer.
///
/// Absent rather than empty for a reason that shows on screen: a chat
/// template branches on whether tools were given, and an empty list
/// renders the whole "you may call one or more functions" preamble with no
/// functions under it.
///
/// `chosen` names which of the *server's* tools this session offers; an
/// MCP server's are the session's own to begin with, since they were
/// configured against this provider rather than found on the machine.
pub fn offered(&self, chosen: &Chosen) -> Option<Vec<&Value>> {
let offered: Vec<&Value> = self
.definitions
.iter()
.filter(|(name, _)| !self.server.contains_key(name) || chosen.takes(name))
.map(|(_, definition)| definition)
.collect();
(!offered.is_empty()).then_some(offered)
}
/// Whether this is a tool this session has, which decides what to do
/// about a call naming something else.
///
/// `chosen` for the same reason [`offered`](Self::offered) takes it: a
/// tool the session did not offer is one the model invented, and being
/// told that is better than being asked for permission to run something
/// that would then be refused. [`execute`](Self::execute) checks it as
/// well, because that is where running it is actually prevented.
pub fn knows(&self, name: &str, chosen: &Chosen) -> bool {
(self.server.contains_key(name) && chosen.takes(name)) || self.mcp_for(name).is_some()
}
/// The MCP server that offered `name`, if one did.
fn mcp_for(&self, name: &str) -> Option<&Arc<Mutex<McpServer>>> {
self.mcp.iter().find(|server| {
server
.lock()
.unwrap()
.tools()
.iter()
.any(|tool| tool.qualified == name)
})
}
/// Runs one call and returns what the model should read.
///
/// `cwd` is the session's working directory, sent only to the tools that
/// say they use one. A session with no working directory sends none, and
/// `llama-server` falls back to its own -- which is the honest outcome:
/// this server has no better answer for where "here" is.
///
/// It must already be **absolute**: the header is passed to `chdir` with
/// no shell in the way, so a leading `~` is a directory of that name and
/// every tool using one fails with "failed to spawn process". The driver
/// resolves it on the serving machine at launch; see
/// `files::resolve_blocking`.
pub fn execute(
&self,
name: &str,
arguments: &Value,
cwd: Option<&str>,
chosen: &Chosen,
) -> Result<String> {
if let Some(server) = self.mcp_for(name) {
return server.lock().unwrap().call(name, arguments);
}
// A tool this session did not offer is not one it may run, even where
// the server has it. A model that names one anyway is guessing, and a
// session whose whole setting was to have no tools must not get a
// shell out of a guess.
let uses_cwd = *self
.server
.get(name)
.filter(|_| chosen.takes(name))
.with_context(|| format!("no tool called {name}"))?;
let mut request = ureq::post(format!("{}/tools", self.endpoint))
.config()
.timeout_global(Some(EXECUTE_TIMEOUT))
// The refusal is a sentence the model can act on, so it is read as
// one rather than discarded in favour of its status code -- the
// same reason `super::refusal` exists for generation.
.http_status_as_error(false)
.build()
.header("content-type", "application/json");
if let (true, Some(cwd)) = (uses_cwd, cwd) {
request = request.header("x-tool-cwd", cwd);
}
let mut response = request
.send_json(json!({"tool": name, "params": arguments}))
.with_context(|| format!("asking llama-server to run {name}"))?;
let body = response
.body_mut()
.read_to_string()
.with_context(|| format!("reading what {name} produced"))?;
Ok(match serde_json::from_str::<Value>(&body) {
Ok(answer) => result_text(&answer),
// Not JSON at all: hand over what was said rather than a parse
// error about it, since the model is what has to carry on.
Err(_) => body,
})
}
}
/// Which of the server's own tools a session offers its model.
///
/// Held by the session rather than settled at discovery, so that changing it
/// takes effect on the next message: one shared server has one set of tools,
/// and which of them go into a request is this.
///
/// Three cases rather than a list of names, because two of them are what
/// people actually write: everything, nothing, or these. "Nothing" is the one
/// that has to be sayable at all -- an empty list would be indistinguishable
/// from the setting being unset, which is what `all` means.
pub enum Chosen {
All,
None,
Named(std::collections::HashSet<String>),
}
impl Chosen {
pub fn from(wanted: Option<&str>) -> Self {
match wanted.map(str::trim) {
None | Some("") | Some("all") => Self::All,
Some("none") => Self::None,
Some(list) => Self::Named(
list.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect(),
),
}
}
pub fn takes(&self, name: &str) -> bool {
match self {
Self::All => true,
Self::None => false,
Self::Named(names) => names.contains(name),
}
}
}
/// `POST /tools`'s answer as the text a model is given.
///
/// The server answers `plain_text_response` for a tool that ran and `error`
/// for one that did not, and both are the model's business -- see this
/// module's note on failures being results. Anything else is handed over as
/// itself rather than discarded, since a tool this build has not seen before
/// is exactly the case where guessing is worst.
fn result_text(answer: &Value) -> String {
if let Some(text) = answer.get("plain_text_response").and_then(Value::as_str) {
return text.to_string();
}
if let Some(message) = answer.get("error").and_then(Value::as_str) {
return message.to_string();
}
answer.to_string()
}
/// How much a session asks before it acts.
///
/// Two, because two is what the mechanism underneath actually has. The web UI
/// that ships with `llama-server` asks before every call and remembers the
/// tools you said "always" to, and that pair -- a prompt and a growing set of
/// exceptions -- is the whole of its permission model. A third mode sitting
/// between them would have to invent a rule about which tools are "edits",
/// and the rule would be this app's opinion rather than anything the tools
/// declare.
pub const MODES: &[&str] = &["manual", "bypassPermissions"];
/// What a new llama session asks by default.
///
/// The cautious one, matching the web UI: a model with a shell on somebody's
/// own machine is the case to be wrong about in this direction, and one tap
/// on "always allow" is what makes it bearable afterwards.
pub const DEFAULT_MODE: &str = "manual";
/// The answer that makes an allowance permanent for the session. The tool's
/// name follows it, which is what makes the transcript alone enough to
/// rebuild the set -- see `super::allowed`.
pub const ALWAYS_PREFIX: &str = "Always allow ";
pub const ALLOW_ONCE: &str = "Allow once";
pub const REFUSE: &str = "Don't allow";
/// What the model is told when a call was refused.
///
/// Addressed to the model, not to the reader: it has to understand that the
/// work did not happen and that trying the same call again is not the way
/// round it, or it retries in a loop.
pub const REFUSED: &str = "The person using this session did not allow this call, so it was not run. Do not try it \
again -- say what you were going to do and why it needed that, and let them decide.";
/// What stands in for a call that never finished, when the transcript is read
/// back into a conversation.
///
/// Every tool call in the history owes a result, because that is the shape a
/// chat template renders; a turn stopped between the call and its result
/// leaves one that has none. Saying so is better than inventing an outcome,
/// and better than dropping the call -- which would tell the model it never
/// asked.
pub const UNFINISHED: &str = "This call was interrupted before it produced anything.";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_tool_that_ran_reads_as_its_output() {
assert_eq!(
result_text(&json!({"plain_text_response": "hello\n"})),
"hello\n",
);
}
#[test]
/// The model is told what went wrong, because the model is what has to do
/// something about it -- read a different path, fix the command.
fn a_tool_that_failed_reads_as_its_message() {
assert_eq!(
result_text(&json!({"error": "cannot stat file: /tmp/nope"})),
"cannot stat file: /tmp/nope",
);
}
#[test]
fn anything_else_is_handed_over_as_itself() {
assert_eq!(result_text(&json!({"rows": 2})), "{\"rows\":2}");
}
/// The vocabulary is `llama-server`'s own, and "none" has to mean it:
/// before the router, that word was a flag the server refused and so a
/// session that never started.
#[test]
fn which_tools_a_session_offers_reads_the_servers_own_words() {
for unset in [None, Some(""), Some(" all ")] {
assert!(Chosen::from(unset).takes("read_file"), "{unset:?}");
}
assert!(!Chosen::from(Some("none")).takes("read_file"));
let two = Chosen::from(Some("read_file, grep_search"));
assert!(two.takes("read_file"));
assert!(two.takes("grep_search"));
assert!(!two.takes("exec_shell_command"));
}
/// The filter is over the *machine's* tools. An MCP server's belong to the
/// session already -- they were configured against this provider rather
/// than found on the machine -- so "none" is a session that still searches
/// the web, and a request with nothing left in it offers no `tools` key at
/// all rather than an empty one.
#[test]
fn a_filtered_catalog_keeps_the_mcp_tools_and_vanishes_when_empty() {
let tools = Tools {
endpoint: "http://127.0.0.1:1".to_string(),
definitions: vec![
("read_file".to_string(), json!({"name": "read_file"})),
("exec_shell_command".to_string(), json!({"name": "shell"})),
("exa_web_search_exa".to_string(), json!({"name": "search"})),
],
server: [
("read_file".to_string(), true),
("exec_shell_command".to_string(), false),
]
.into_iter()
.collect(),
mcp: Vec::new(),
};
let offered = |wanted| {
tools
.offered(&Chosen::from(wanted))
.map(|offered| offered.len())
};
assert_eq!(offered(None), Some(3));
assert_eq!(offered(Some("read_file")), Some(2));
assert_eq!(offered(Some("none")), Some(1));
let nothing = Tools {
definitions: Vec::new(),
..tools
};
assert_eq!(nothing.offered(&Chosen::All), None);
}
}
+637 -186
View File
File diff suppressed because it is too large. Load diff
+20 -20
View File
@@ -48,16 +48,16 @@ impl Operation {
#[serde(rename_all = "camelCase", tag = "state")] #[serde(rename_all = "camelCase", tag = "state")]
pub enum Change { pub enum Change {
Started { Started {
setup: String, machine: String,
session: String, session: String,
operation: Operation, operation: Operation,
}, },
Finished { Finished {
setup: String, machine: String,
session: String, session: String,
}, },
Failed { Failed {
setup: String, machine: String,
session: String, session: String,
message: String, message: String,
}, },
@@ -66,11 +66,11 @@ pub enum Change {
impl Change { impl Change {
/// Which machine this is about, so a stream scoped to one can drop the rest. /// Which machine this is about, so a stream scoped to one can drop the rest.
/// Every variant carries it; matching here keeps that fact in one place. /// Every variant carries it; matching here keeps that fact in one place.
pub fn setup(&self) -> &str { pub fn machine(&self) -> &str {
match self { match self {
Self::Started { setup, .. } Self::Started { machine, .. }
| Self::Finished { setup, .. } | Self::Finished { machine, .. }
| Self::Failed { setup, .. } => setup, | Self::Failed { machine, .. } => machine,
} }
} }
} }
@@ -106,12 +106,12 @@ impl Registry {
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports /// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports
/// a failure. Dropping without settling means the task was cancelled or /// a failure. Dropping without settling means the task was cancelled or
/// panicked, and a row stuck on "importing" for ever is a worse answer. /// panicked, and a row stuck on "importing" for ever is a worse answer.
pub fn begin(self: &Arc<Self>, setup: &str, session: &str, operation: Operation) -> InFlight { pub fn begin(self: &Arc<Self>, machine: &str, session: &str, operation: Operation) -> InFlight {
let key = (setup.to_string(), session.to_string()); let key = (machine.to_string(), session.to_string());
self.running.lock().unwrap().insert(key.clone(), operation); self.running.lock().unwrap().insert(key.clone(), operation);
self.failures.lock().unwrap().remove(&key); self.failures.lock().unwrap().remove(&key);
let _ = self.changes.send(Change::Started { let _ = self.changes.send(Change::Started {
setup: key.0.clone(), machine: key.0.clone(),
session: key.1.clone(), session: key.1.clone(),
operation, operation,
}); });
@@ -123,25 +123,25 @@ impl Registry {
} }
/// What is happening to this session, if anything is. /// What is happening to this session, if anything is.
pub fn running(&self, setup: &str, session: &str) -> Option<Operation> { pub fn running(&self, machine: &str, session: &str) -> Option<Operation> {
let key = (setup.to_string(), session.to_string()); let key = (machine.to_string(), session.to_string());
self.running.lock().unwrap().get(&key).copied() self.running.lock().unwrap().get(&key).copied()
} }
/// How the last operation on this session failed, if it did. /// How the last operation on this session failed, if it did.
pub fn failure(&self, setup: &str, session: &str) -> Option<String> { pub fn failure(&self, machine: &str, session: &str) -> Option<String> {
let key = (setup.to_string(), session.to_string()); let key = (machine.to_string(), session.to_string());
self.failures.lock().unwrap().get(&key).cloned() self.failures.lock().unwrap().get(&key).cloned()
} }
/// Forgets failures against sessions the machine no longer has. Called from /// Forgets failures against sessions the machine no longer has. Called from
/// the listing, which is the only place that knows what is still there. /// the listing, which is the only place that knows what is still there.
pub fn prune(&self, setup: &str, present: &[String]) { pub fn prune(&self, machine: &str, present: &[String]) {
self.failures self.failures
.lock() .lock()
.unwrap() .unwrap()
.retain(|(kept_setup, session), _| { .retain(|(kept_machine, session), _| {
kept_setup != setup || present.iter().any(|id| id == session) kept_machine != machine || present.iter().any(|id| id == session)
}); });
} }
@@ -174,7 +174,7 @@ impl InFlight {
} }
self.settled = true; self.settled = true;
self.registry.running.lock().unwrap().remove(&self.key); self.registry.running.lock().unwrap().remove(&self.key);
let (setup, session) = (self.key.0.clone(), self.key.1.clone()); let (machine, session) = (self.key.0.clone(), self.key.1.clone());
let change = match failure { let change = match failure {
Some(message) => { Some(message) => {
self.registry self.registry
@@ -183,12 +183,12 @@ impl InFlight {
.unwrap() .unwrap()
.insert(self.key.clone(), message.clone()); .insert(self.key.clone(), message.clone());
Change::Failed { Change::Failed {
setup, machine,
session, session,
message, message,
} }
} }
None => Change::Finished { setup, session }, None => Change::Finished { machine, session },
}; };
let _ = self.registry.changes.send(change); let _ = self.registry.changes.send(change);
} }
+125 -9
View File
@@ -30,6 +30,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const RECORD_FILE: &str = "process.json"; const RECORD_FILE: &str = "process.json";
const STOP_REQUEST_FILE: &str = "stop-requested";
/// A process this server started and expects to outlive it. /// A process this server started and expects to outlive it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -55,6 +56,16 @@ pub enum Detail {
/// Spoken to over HTTP on a loopback port, which is all it takes to find /// Spoken to over HTTP on a loopback port, which is all it takes to find
/// it again -- there is no stream to be partway through. /// it again -- there is no stream to be partway through.
Http { port: u16 }, Http { port: u16 },
/// The same, for a process this session reaches but does not own: the
/// llama.cpp router serving every session on its machine.
///
/// A variant rather than a flag because of what it forbids. Liveness is
/// the identical question -- a session whose router has gone has no model
/// -- but ending it is not this session's to ask, and [`signal`] is where
/// that is enforced: stopping, deleting or cleaning up after a session
/// must not take a model out of memory for every other session on that
/// machine.
Shared { port: u16 },
} }
/// Whether a recorded process is still there. /// Whether a recorded process is still there.
@@ -81,6 +92,15 @@ impl Record {
}) })
} }
/// Whether this server may end that process.
///
/// False for the one it shares -- see [`Detail::Shared`]. Liveness is the
/// identical question for both, which is why this is separate from it:
/// "is it there?" and "is it mine to end?" are asked in different places.
pub fn ours(&self) -> bool {
!matches!(self.detail, Detail::Shared { .. })
}
pub fn liveness(&self) -> Liveness { pub fn liveness(&self) -> Liveness {
match stat_of(self.pid) { match stat_of(self.pid) {
// A different start time is a reused pid, so definitely not ours. // A different start time is a reused pid, so definitely not ours.
@@ -101,6 +121,28 @@ fn path(session_dir: &Path) -> PathBuf {
session_dir.join(RECORD_FILE) session_dir.join(RECORD_FILE)
} }
fn stop_request_path(session_dir: &Path) -> PathBuf {
session_dir.join(STOP_REQUEST_FILE)
}
/// Marks the process as one the server deliberately asked to end, so its watcher reports an exit
/// without presenting ordinary stderr from the process's lifetime as the cause.
pub fn mark_stopping(session_dir: &Path) -> Result<()> {
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(stop_request_path(session_dir))
.with_context(|| format!("marking {} as stopping", session_dir.display()))?;
Ok(())
}
/// Whether this process's exit was deliberately requested.
pub fn stopping(session_dir: &Path) -> bool {
stop_request_path(session_dir).is_file()
}
/// The recorded process and whether it is still there, or `None` when nothing /// The recorded process and whether it is still there, or `None` when nothing
/// usable is recorded. A record that does not parse reads as no record: the /// usable is recorded. A record that does not parse reads as no record: the
/// only way to get one is a crash partway through writing it, and the safe /// only way to get one is a crash partway through writing it, and the safe
@@ -179,12 +221,46 @@ pub fn size_of(path: &Path) -> u64 {
/// Forgets the recorded process -- for one confirmed dead, or a session /// Forgets the recorded process -- for one confirmed dead, or a session
/// being deleted. The path out for [`write`]. /// being deleted. The path out for [`write`].
pub fn clear(session_dir: &Path) { pub fn clear(session_dir: &Path) {
let path = path(session_dir); for path in [path(session_dir), stop_request_path(session_dir)] {
if let Err(err) = std::fs::remove_file(&path) if let Err(err) = std::fs::remove_file(&path)
&& err.kind() != std::io::ErrorKind::NotFound && err.kind() != std::io::ErrorKind::NotFound
{ {
tracing::warn!("couldn't remove {}: {err}", path.display()); tracing::warn!("couldn't remove {}: {err}", path.display());
} }
}
}
/// Creates a session stdin fifo and opens it read-write for the child.
///
/// The child holding the write end is what keeps a detached JSON server from
/// reading EOF when ai-server restarts and temporarily closes its own writer.
pub 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: `c_path` is nul-terminated and this call only reads it.
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 owner-only log for a detached session process.
pub 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()))
} }
/// Grace period between asking a session's process to stop and killing it. /// Grace period between asking a session's process to stop and killing it.
@@ -199,10 +275,10 @@ pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
/// a SIGKILL would cost whatever it had not flushed; SIGKILL after the grace /// a SIGKILL would cost whatever it had not flushed; SIGKILL after the grace
/// period because a session the phone has deleted must not still be running. /// period because a session the phone has deleted must not still be running.
pub fn stop(record: &Record, grace: std::time::Duration) { pub fn stop(record: &Record, grace: std::time::Duration) {
if record.liveness() != Liveness::Alive { if !record.ours() || record.liveness() != Liveness::Alive {
return; return;
} }
signal(record.pid, libc::SIGTERM); signal(record, libc::SIGTERM);
let record = record.clone(); let record = record.clone();
tokio::spawn(async move { tokio::spawn(async move {
tokio::time::sleep(grace).await; tokio::time::sleep(grace).await;
@@ -228,6 +304,10 @@ pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
let deadline = std::time::Instant::now() + grace; let deadline = std::time::Instant::now() + grace;
for record in records { for record in records {
// Never asked to stop, so there is nothing to wait out.
if !record.ours() {
continue;
}
while record.liveness() == Liveness::Alive && std::time::Instant::now() < deadline { while record.liveness() == Liveness::Alive && std::time::Instant::now() < deadline {
std::thread::sleep(LOOK); std::thread::sleep(LOOK);
} }
@@ -246,17 +326,25 @@ fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
record.pid, record.pid,
grace grace
); );
signal(record.pid, libc::SIGKILL); signal(record, libc::SIGKILL);
} }
} }
fn signal(pid: u32, signal: libc::c_int) { /// The one place a session's process is signalled, which is why the refusal to
/// signal a shared one lives here rather than at each caller: every path out of
/// a session -- stopped, deleted, cleaned up on the way down -- ends in this
/// function, and the one that forgot would be a model unloaded under somebody
/// else's turn.
fn signal(record: &Record, signal: libc::c_int) {
if !record.ours() {
return;
}
// SAFETY: `kill` with a positive pid touches only that process, and the pid // SAFETY: `kill` with a positive pid touches only that process, and the pid
// came from a record whose start time was just confirmed to match -- so it // came from a record whose start time was just confirmed to match -- so it
// is still the process this server started, not a reused number. A failure // is still the process this server started, not a reused number. A failure
// (already gone) is nothing to act on. // (already gone) is nothing to act on.
unsafe { unsafe {
libc::kill(pid as libc::pid_t, signal); libc::kill(record.pid as libc::pid_t, signal);
} }
} }
@@ -367,13 +455,18 @@ mod tests {
write(dir.path(), &record); write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record.clone())); assert_eq!(live(dir.path()), Some(record.clone()));
// And the other shape round trips through the same file. // And the other shapes round trip through the same file.
record.detail = Detail::Http { port: 8080 }; for detail in [Detail::Http { port: 8080 }, Detail::Shared { port: 8080 }] {
record.detail = detail;
write(dir.path(), &record); write(dir.path(), &record);
assert_eq!(live(dir.path()), Some(record)); assert_eq!(live(dir.path()), Some(record.clone()));
}
mark_stopping(dir.path()).expect("mark stopping");
assert!(stopping(dir.path()));
clear(dir.path()); clear(dir.path());
assert_eq!(live(dir.path()), None); assert_eq!(live(dir.path()), None);
assert!(!stopping(dir.path()));
} }
#[test] #[test]
@@ -403,6 +496,29 @@ mod tests {
assert!(stray.is_empty(), "left behind {stray:?}"); assert!(stray.is_empty(), "left behind {stray:?}");
} }
/// The whole of what [`Detail::Shared`] is for: a session ending must not
/// take the machine's llama.cpp router with it.
#[tokio::test]
async fn a_shared_process_is_not_stopped_with_the_session_that_reached_it() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let shared = Record::of(child.id(), Detail::Shared { port: 1 }).expect("start time");
stop(&shared, std::time::Duration::from_millis(50));
std::thread::sleep(std::time::Duration::from_millis(200));
assert_eq!(shared.liveness(), Liveness::Alive, "the router was killed");
// The same process, recorded as one this session owns, does stop.
let owned = Record {
detail: Detail::Http { port: 1 },
..shared
};
stop(&owned, std::time::Duration::from_millis(50));
let _ = child.wait();
assert_eq!(owned.liveness(), Liveness::Dead);
}
#[test] #[test]
fn a_dead_or_unreadable_record_is_not_live() { fn a_dead_or_unreadable_record_is_not_live() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
+237 -23
View File
@@ -7,9 +7,10 @@
//! work on a subagent's file unchanged. //! work on a subagent's file unchanged.
//! //!
//! Storage is `<session dir>/subagents/<id>/{meta.json,transcript.jsonl}`, //! Storage is `<session dir>/subagents/<id>/{meta.json,transcript.jsonl}`,
//! where `<id>` is the Task tool_use id that started it -- unique, stable //! where `<id>` is Claude's Task tool_use id or Codex's child thread id --
//! across a backend restart, and already the key the parent side uses. Only //! unique, stable across a backend restart, and already the key the parent
//! ids matching [`is_subagent_id`] are ever turned into a path. //! side uses. Only ids matching [`is_subagent_id`] are ever turned into a
//! path.
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
@@ -67,6 +68,9 @@ pub struct SubagentInfo {
/// session's but with no driver behind it. /// session's but with no driver behind it.
pub struct Subagent { pub struct Subagent {
dir: PathBuf, dir: PathBuf,
/// Its own name, from `meta.json`, so an open subagent can be listed
/// without reading every subagent's directory back off disk.
title: String,
transcript: Mutex<Transcript>, transcript: Mutex<Transcript>,
events: broadcast::Sender<SeqEvent>, events: broadcast::Sender<SeqEvent>,
/// Mirrors the transcript's last `Status` event, kept live rather than /// Mirrors the transcript's last `Status` event, kept live rather than
@@ -80,6 +84,10 @@ pub struct Subagent {
} }
impl Subagent { impl Subagent {
fn title(&self) -> &str {
&self.title
}
pub fn transcript_path(&self) -> PathBuf { pub fn transcript_path(&self) -> PathBuf {
self.dir.join("transcript.jsonl") self.dir.join("transcript.jsonl")
} }
@@ -96,7 +104,7 @@ impl Subagent {
*self.status.lock().unwrap() != SessionStatus::Exited *self.status.lock().unwrap() != SessionStatus::Exited
} }
fn append(&self, event: Event) { fn append(&self, event: Event) -> bool {
let mut transcript = self.transcript.lock().unwrap(); let mut transcript = self.transcript.lock().unwrap();
match transcript.append(event, super::now()) { match transcript.append(event, super::now()) {
Ok(entry) => { Ok(entry) => {
@@ -106,14 +114,18 @@ impl Subagent {
// No subscribers is fine; the transcript already has it, // No subscribers is fine; the transcript already has it,
// same as a session's pump. // same as a session's pump.
let _ = self.events.send(entry); let _ = self.events.send(entry);
true
}
Err(err) => {
tracing::error!("subagent transcript append failed: {err:#}");
false
} }
Err(err) => tracing::error!("subagent transcript append failed: {err:#}"),
} }
} }
} }
/// Every subagent one session has started, keyed by the Task tool_use id /// Every subagent one session has started, keyed by the provider's stable
/// that names it. /// parent-side id for it.
/// ///
/// Lives beside a session's driver rather than inside it: a claude driver /// Lives beside a session's driver rather than inside it: a claude driver
/// holds an `Arc` to this and routes child lines into it; echo uses it for /// holds an `Arc` to this and routes child lines into it; echo uses it for
@@ -124,14 +136,28 @@ pub struct Subagents {
/// The session's own directory; subagents live under `<dir>/subagents`. /// The session's own directory; subagents live under `<dir>/subagents`.
dir: PathBuf, dir: PathBuf,
live: Mutex<HashMap<String, Arc<Subagent>>>, live: Mutex<HashMap<String, Arc<Subagent>>>,
/// Open subagents by id, with the title each is known by, seeded from disk so an adopted
/// session starts with the measured set rather than waiting to see lifecycle edges which are
/// already behind its stdout cursor. The title is held here so that listing what is open
/// costs no directory read -- `GET /sessions/{id}/background` asks often.
open: Mutex<HashMap<String, String>>,
} }
impl Subagents { impl Subagents {
pub fn new(session_dir: PathBuf) -> Self { pub fn new(session_dir: PathBuf) -> Self {
Self { let subagents = Self {
dir: session_dir, dir: session_dir,
live: Mutex::new(HashMap::new()), live: Mutex::new(HashMap::new()),
} open: Mutex::new(HashMap::new()),
};
subagents.open.lock().unwrap().extend(
subagents
.list(true)
.into_iter()
.filter(|info| info.status == SessionStatus::Running)
.map(|info| (info.id, info.title)),
);
subagents
} }
fn subagents_dir(&self) -> PathBuf { fn subagents_dir(&self) -> PathBuf {
@@ -200,6 +226,7 @@ impl Subagents {
let (events, _) = broadcast::channel(EVENT_BUFFER); let (events, _) = broadcast::channel(EVENT_BUFFER);
Ok(Arc::new(Subagent { Ok(Arc::new(Subagent {
dir, dir,
title: meta.title,
transcript: Mutex::new(transcript), transcript: Mutex::new(transcript),
events, events,
status: Mutex::new(status), status: Mutex::new(status),
@@ -221,6 +248,12 @@ impl Subagents {
} }
match self.open_or_create(id, title, prompt) { match self.open_or_create(id, title, prompt) {
Ok(subagent) => { Ok(subagent) => {
if subagent.is_open() {
self.open
.lock()
.unwrap()
.insert(id.to_string(), subagent.title().to_string());
}
live.insert(id.to_string(), subagent); live.insert(id.to_string(), subagent);
} }
Err(err) => tracing::error!("couldn't start subagent {id}: {err:#}"), Err(err) => tracing::error!("couldn't start subagent {id}: {err:#}"),
@@ -258,13 +291,54 @@ impl Subagents {
} }
} }
/// Whether the subagent named `id` exists and has not finished. `false`
/// for an id that is not a subagent's at all -- a backgrounded command's
/// tool call reaches here with the same shape.
pub fn is_open(&self, id: &str) -> bool {
self.get(id).is_some_and(|subagent| subagent.is_open())
}
/// Whether this session has any subagent still working.
///
/// That is the whole point of it. A backend restart adopts a session's
/// process and picks its stdout back up from a recorded offset, so the
/// `task_started` lines for subagents launched before the restart are
/// already behind that offset and the translator never sees them -- it
/// starts with an empty set and reports the session `Idle` at the end of
/// a turn it should have called [`SessionStatus::Waiting`].
pub fn any_open(&self, session_running: bool) -> bool {
session_running && self.open_count() > 0
}
/// Latest measured number of live subagents, including ones found on disk at construction.
pub fn open_count(&self) -> usize {
self.open.lock().unwrap().len()
}
/// The live subagents, each with the title it is known by. Ordered by
/// id, which says nothing about when they started but does mean two
/// readings agree; read from memory, so a caller may ask often.
pub fn open_list(&self) -> Vec<(String, String)> {
let mut open: Vec<(String, String)> = self
.open
.lock()
.unwrap()
.iter()
.map(|(id, title)| (id.clone(), title.clone()))
.collect();
open.sort();
open
}
/// Appends one event to a subagent's own transcript. A no-op, with a /// Appends one event to a subagent's own transcript. A no-op, with a
/// debug log, for an id nothing was started under -- a child line for a /// debug log, for an id nothing was started under -- a child line for a
/// subagent this registry never opened is dropped rather than guessed /// subagent this registry never opened is dropped rather than guessed
/// at. /// at.
pub fn record(&self, id: &str, event: Event) { pub fn record(&self, id: &str, event: Event) {
match self.live.lock().unwrap().get(id).cloned() { match self.live.lock().unwrap().get(id).cloned() {
Some(subagent) => subagent.append(event), Some(subagent) => {
subagent.append(event);
}
None => tracing::debug!("dropping an event for unknown subagent {id}"), None => tracing::debug!("dropping an event for unknown subagent {id}"),
} }
} }
@@ -278,10 +352,11 @@ impl Subagents {
pub fn finish(&self, id: &str) { pub fn finish(&self, id: &str) {
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned() if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
&& subagent.is_open() && subagent.is_open()
{ && subagent.append(Event::Status {
subagent.append(Event::Status {
state: SessionStatus::Exited, state: SessionStatus::Exited,
}); })
{
self.open.lock().unwrap().remove(id);
} }
} }
@@ -293,24 +368,84 @@ impl Subagents {
pub fn reopen(&self, id: &str) { pub fn reopen(&self, id: &str) {
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned() if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
&& !subagent.is_open() && !subagent.is_open()
{ && subagent.append(Event::Status {
subagent.append(Event::Status {
state: SessionStatus::Running, state: SessionStatus::Running,
}); })
{
self.open
.lock()
.unwrap()
.insert(id.to_string(), subagent.title().to_string());
} }
} }
/// The parent session's process is gone, so nothing still open here has /// The parent session's process is gone, so nothing still open here has
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #4. /// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #5.
///
/// Read from the directory rather than from `live`, because a subagent
/// left `Running` by a *previous* run of this server is exactly the one
/// that needs closing and is the one `live` does not have: nothing in
/// this process ever touched it, so it would keep reading `running`
/// every time its session was started again, with nothing able to
/// correct it.
pub fn finish_all(&self) { pub fn finish_all(&self) {
let subagents: Vec<Arc<Subagent>> = self.live.lock().unwrap().values().cloned().collect(); // `list(true)` reports each one's own last status rather than
for subagent in subagents { // rewriting a running one as unknown -- what is wanted here is which
if subagent.is_open() { // are open on disk, and this call is the very thing that decides the
subagent.append(Event::Status { // session is not running.
state: SessionStatus::Exited, for info in self.list(true) {
}); if info.status != SessionStatus::Running {
continue;
}
let _ = self.get(&info.id);
self.finish(&info.id);
} }
} }
/// Removes subagents, transcripts and all -- `POST
/// /sessions/{id}/subagents/delete`, and the path out for [`start`]
/// short of deleting the whole session.
///
/// **All or nothing, and only for one that has finished.** Every id is
/// checked before anything is removed, so a batch naming one that is
/// still running leaves the others exactly as they were rather than
/// deleting up to the offender -- the reader picked a set, and a set
/// half-deleted is indistinguishable, on the list, from rows they never
/// picked. Refusing a running one is not withholding the capability:
/// its transcript is still being written to, and its process is the
/// session's to stop.
///
/// `session_running` decides what "running" means here, exactly as it
/// does in [`Subagents::list`].
///
/// [`start`]: Subagents::start
pub fn delete(&self, ids: &[String], session_running: bool) -> Result<()> {
let dirs: Vec<PathBuf> = ids
.iter()
.map(|id| {
anyhow::ensure!(is_subagent_id(id), "{id} is not a subagent id");
Ok(self.subagents_dir().join(id))
})
.collect::<Result<_>>()?;
for (id, dir) in ids.iter().zip(&dirs) {
let info = info_of(dir, session_running)
.with_context(|| format!("there is no subagent {id} here"))?;
anyhow::ensure!(
info.status != SessionStatus::Running,
"\"{}\" is still running -- it can be deleted once it has finished",
info.title
);
}
let mut live = self.live.lock().unwrap();
for (id, dir) in ids.iter().zip(&dirs) {
fs::remove_dir_all(dir).with_context(|| format!("delete {}", dir.display()))?;
// Out of the registry as well as off the disk, so a later child
// line for this id starts a new subagent rather than appending
// to an unlinked file nothing can read.
live.remove(id);
self.open.lock().unwrap().remove(id);
}
Ok(())
} }
/// Every subagent under this session's directory, oldest first -- /// Every subagent under this session's directory, oldest first --
@@ -411,6 +546,7 @@ mod tests {
.count(), .count(),
1 1
); );
assert_eq!(subagents.open_count(), 1);
} }
#[test] #[test]
@@ -428,6 +564,7 @@ mod tests {
} }
// A fresh registry, the way a backend restart builds one. // A fresh registry, the way a backend restart builds one.
let subagents = Subagents::new(dir.path().to_path_buf()); let subagents = Subagents::new(dir.path().to_path_buf());
assert_eq!(subagents.open_count(), 1);
let subagent = subagents.get("toolu_2").expect("reopened"); let subagent = subagents.get("toolu_2").expect("reopened");
assert!(subagent.is_open()); assert!(subagent.is_open());
subagents.record( subagents.record(
@@ -449,6 +586,7 @@ mod tests {
let subagents = Subagents::new(dir.path().to_path_buf()); let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_3", "helper", None); subagents.start("toolu_3", "helper", None);
subagents.finish("toolu_3"); subagents.finish("toolu_3");
assert_eq!(subagents.open_count(), 0);
let subagent = subagents.get("toolu_3").unwrap(); let subagent = subagents.get("toolu_3").unwrap();
assert!(!subagent.is_open()); assert!(!subagent.is_open());
// On disk too, not only in the live cache `is_open` reads. // On disk too, not only in the live cache `is_open` reads.
@@ -461,6 +599,82 @@ mod tests {
// Finishing an id that was never a subagent is a no-op, not a panic. // Finishing an id that was never a subagent is a no-op, not a panic.
subagents.finish("never-started"); subagents.finish("never-started");
subagents.reopen("toolu_3");
assert_eq!(subagents.open_count(), 1);
subagents.finish("toolu_3");
assert_eq!(subagents.open_count(), 0);
}
#[test]
fn finish_all_closes_one_left_running_by_an_earlier_run() {
// The state a backend restart leaves behind: the subagent is on disk
// reading `Running` and nothing in this process has touched it, so a
// registry that only knew its own `live` map left it running for
// ever -- and the phone showed it as running every time the session
// was started again.
let dir = tempfile::tempdir().expect("tempdir");
{
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_stale", "helper", None);
}
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.finish_all();
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].status, SessionStatus::Exited);
}
#[test]
fn deleting_a_finished_subagent_takes_its_directory_with_it() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_done", "helper", None);
subagents.finish("toolu_done");
subagents
.delete(&["toolu_done".to_string()], true)
.expect("delete");
assert!(subagents.list(true).is_empty());
assert!(!dir.path().join("subagents").join("toolu_done").exists());
// Out of the live registry too, so a later line starts a new one rather than appending to
// a file nothing can read.
assert!(subagents.get("toolu_done").is_none());
}
#[test]
fn deleting_a_batch_with_a_running_one_in_it_deletes_none_of_it() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_done", "finished helper", None);
subagents.finish("toolu_done");
subagents.start("toolu_busy", "busy helper", None);
let err = subagents
.delete(&["toolu_done".to_string(), "toolu_busy".to_string()], true)
.expect_err("refused");
assert!(err.to_string().contains("busy helper"), "{err:#}");
assert_eq!(subagents.list(true).len(), 2);
// The same batch once the session behind it has no process: nothing there is running, so
// both go.
subagents
.delete(&["toolu_done".to_string(), "toolu_busy".to_string()], false)
.expect("delete");
assert!(subagents.list(false).is_empty());
}
#[test]
fn deleting_an_id_that_is_not_there_is_refused_rather_than_ignored() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
assert!(
subagents
.delete(&["toolu_ghost".to_string()], true)
.is_err()
);
assert!(subagents.delete(&["../../etc".to_string()], true).is_err());
} }
#[test] #[test]
+357 -28
View File
@@ -6,6 +6,7 @@
//! Reopening an existing file continues the numbering, which is what makes //! Reopening an existing file continues the numbering, which is what makes
//! a backend restart invisible to a phone holding a cursor. //! a backend restart invisible to a phone holding a cursor.
use std::collections::{HashMap, HashSet};
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::Write; use std::io::Write;
use std::ops::Range; use std::ops::Range;
@@ -15,7 +16,7 @@ use std::path::Path;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::driver::{Event, SessionStatus, context_after}; use super::driver::{Event, SessionStatus, context_after, context_limit_after};
/// One transcript line: an [`Event`] plus its position and time. The event /// One transcript line: an [`Event`] plus its position and time. The event
/// is flattened so the wire shape stays one flat object. /// is flattened so the wire shape stays one flat object.
@@ -34,6 +35,7 @@ pub struct Transcript {
last_status: Option<SessionStatus>, last_status: Option<SessionStatus>,
last_activity: Option<f64>, last_activity: Option<f64>,
context_tokens: Option<u64>, context_tokens: Option<u64>,
context_limit: Option<u64>,
} }
impl Transcript { impl Transcript {
@@ -68,6 +70,12 @@ impl Transcript {
context_tokens: existing context_tokens: existing
.iter() .iter()
.fold(None, |current, entry| context_after(current, &entry.event)), .fold(None, |current, entry| context_after(current, &entry.event)),
// The same fold for the same reason: a session whose process has
// since exited has no window, and the newest `ContextWindow` line
// alone would not know that.
context_limit: existing.iter().fold(None, |current, entry| {
context_limit_after(current, &entry.event)
}),
}) })
} }
@@ -107,6 +115,12 @@ impl Transcript {
self.context_tokens self.context_tokens
} }
/// What that figure is out of, as of opening, and `None` where this
/// session's provider does not say.
pub fn context_limit(&self) -> Option<u64> {
self.context_limit
}
/// Appends `event`, assigning it the next sequence number. Flushed per /// Appends `event`, assigning it the next sequence number. Flushed per
/// event: each line is tiny, and the transcript is the source of truth a /// event: each line is tiny, and the transcript is the source of truth a
/// crash must not lose the tail of. /// crash must not lose the tail of.
@@ -227,6 +241,59 @@ pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
indexed.parse(start..indexed.lines.len()) indexed.parse(start..indexed.lines.len())
} }
/// Where each of `ids` was called, for the ids that are in the transcript:
/// the call's sequence number and the arguments it was made with.
///
/// What this is for is a background task: a provider names the tool call that
/// started one, and both the words the panel draws it with and the place the
/// reader is taken on tapping it come from the call itself. Nothing else here
/// answers "where is this id", because nothing else needed to -- every other
/// reader of a transcript wants a range of it.
///
/// Walked newest-first and stopped as soon as every id is found, since a task
/// that is still running was started recently: the whole file is only parsed
/// for an id that is not in it at all. The substring test before each parse is
/// what keeps that worst case a scan of the text rather than 24,000 parses.
pub fn locate_tool_calls(path: &Path, ids: &HashSet<String>) -> Result<HashMap<String, ToolCall>> {
let mut found = HashMap::new();
if ids.is_empty() {
return Ok(found);
}
let Some(indexed) = Indexed::read(path)? else {
return Ok(found);
};
for index in (0..indexed.lines.len()).rev() {
let line = &indexed.text[indexed.lines[index].clone()];
if !ids.iter().any(|id| line.contains(id.as_str())) {
continue;
}
let entry = indexed.parse_one(index)?;
let Event::ToolStart { id, input, .. } = entry.event else {
continue;
};
if !ids.contains(&id) {
continue;
}
found.insert(
id,
ToolCall {
seq: entry.seq,
input,
},
);
if found.len() == ids.len() {
break;
}
}
Ok(found)
}
/// One tool call as [`locate_tool_calls`] found it.
pub struct ToolCall {
pub seq: u64,
pub input: serde_json::Value,
}
/// The transcript's lines located but not read, so a reader can find the range /// The transcript's lines located but not read, so a reader can find the range
/// it wants and parse only that. /// it wants and parse only that.
/// ///
@@ -309,22 +376,78 @@ impl<'a> Indexed<'a> {
} }
fn parse(&self, range: Range<usize>) -> Result<Vec<SeqEvent>> { fn parse(&self, range: Range<usize>) -> Result<Vec<SeqEvent>> {
self.lines[range] self.lines[range.clone()]
.iter() .iter()
.map(|at| { .enumerate()
serde_json::from_str(&self.text[at.clone()]) .map(|(offset, at)| self.parse_at(range.start + offset, at.clone()))
.with_context(|| format!("bad transcript line in {}", self.path.display()))
})
.collect() .collect()
} }
fn parse_one(&self, index: usize) -> Result<SeqEvent> { fn parse_one(&self, index: usize) -> Result<SeqEvent> {
serde_json::from_str(&self.text[self.lines[index].clone()]) self.parse_at(index, self.lines[index].clone())
.with_context(|| format!("bad transcript line in {}", self.path.display())) }
/// One line, degrading to [`Event::Unreadable`] rather than failing when
/// this build cannot make sense of the event on it.
///
/// **A transcript is append-only and permanent, so the set of kinds that
/// can appear in one only ever grows.** What this build writes is not what
/// it may have to read: a line may come from a newer server, or from an
/// older one that wrote a kind since dropped. Refusing the whole file for
/// one such line is what happened on 2026-09-06 -- a kind was removed after
/// transcripts had recorded it, every read of those files failed, and the
/// sessions in them could not be opened, listed, paged or sent to. One
/// unfamiliar word took down every conversation it appeared in.
///
/// So the line survives as a line. It keeps its seq, which is the part
/// everything downstream is addressed by, and says what it was rather than
/// pretending to be something -- there is a row for that on the phone
/// already.
///
/// The seq itself is still required, and this still fails without one: a
/// line that cannot say where it sits in the sequence is not a line this
/// file can hold, and quietly dropping it would hand out a seq the file
/// already contains.
fn parse_at(&self, index: usize, at: Range<usize>) -> Result<SeqEvent> {
let line = &self.text[at];
match serde_json::from_str(line) {
Ok(entry) => Ok(entry),
Err(err) => {
#[derive(Deserialize)]
struct JustPlace {
seq: u64,
ts: f64,
#[serde(rename = "type")]
kind: Option<String>,
}
let place: JustPlace = serde_json::from_str(line)
.with_context(|| format!("bad transcript line in {}", self.path.display()))?;
// Debug rather than a warning: a transcript written against a
// newer build has one of these per line it wrote, and the row
// on the phone is where this is actually reported.
tracing::debug!(
"transcript line {index} of {} (seq {}) is not one this build can read: {err}",
self.path.display(),
place.seq,
);
Ok(SeqEvent {
seq: place.seq,
ts: place.ts,
event: Event::Unreadable {
kind: place.kind.unwrap_or_else(|| "no kind".to_string()),
},
})
}
}
} }
/// The newest `limit` *rows* ending at line `end`, with each run of /// The newest `limit` *rows* ending at line `end`, with each run of
/// consecutive [`Event::AssistantText`] deltas concatenated into one. /// consecutive deltas of one streamed kind concatenated into one.
///
/// Two kinds stream a token at a time -- [`Event::AssistantText`] and
/// [`Event::Thinking`] -- and a run is of one of them, never of both: they
/// are two rows on screen, and welding them would put a model's working
/// inside what it said.
/// ///
/// A reply is stored a token at a time, so a window counted in events is a /// A reply is stored a token at a time, so a window counted in events is a
/// fraction of a row for a reply and a whole row for a tool call, and the /// fraction of a row for a reply and a whole row for a tool call, and the
@@ -340,17 +463,22 @@ impl<'a> Indexed<'a> {
fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result<Vec<SeqEvent>> { fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
// Newest first while walking back, reversed to transcript order at the end. // Newest first while walking back, reversed to transcript order at the end.
let mut out: Vec<SeqEvent> = Vec::new(); let mut out: Vec<SeqEvent> = Vec::new();
// The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first. // The run currently being gathered: which kind it is, its oldest
let mut run: Option<(u64, f64, Vec<String>)> = None; // seq/ts so far, and its deltas newest-first.
let flush = |run: &mut Option<(u64, f64, Vec<String>)>, out: &mut Vec<SeqEvent>| { let mut run: Option<Run> = None;
if let Some((seq, ts, mut deltas)) = run.take() { let flush = |run: &mut Option<Run>, out: &mut Vec<SeqEvent>| {
if let Some(Run {
kind,
seq,
ts,
mut deltas,
}) = run.take()
{
deltas.reverse(); deltas.reverse();
out.push(SeqEvent { out.push(SeqEvent {
seq, seq,
ts, ts,
event: Event::AssistantText { event: kind.of_delta(deltas.concat()),
delta: deltas.concat(),
},
}); });
} }
}; };
@@ -364,19 +492,35 @@ impl<'a> Indexed<'a> {
} }
index -= 1; index -= 1;
let entry = self.parse_one(index)?; let entry = self.parse_one(index)?;
if let Event::AssistantText { delta } = entry.event { match Streamed::of(entry.event) {
match run { Ok((kind, delta)) => {
Some((ref mut seq, ref mut ts, ref mut deltas)) => { // A run of a different kind ends here, whatever it was
*seq = entry.seq; // gathering: the two are separate rows.
*ts = entry.ts; if run.as_ref().is_some_and(|open| open.kind != kind) {
deltas.push(delta);
}
None => run = Some((entry.seq, entry.ts, vec![delta])),
}
} else {
// The run above this event (newer) is complete: it is a row, and so is this event.
flush(&mut run, &mut out); flush(&mut run, &mut out);
out.push(entry); }
match run {
Some(ref mut open) => {
open.seq = entry.seq;
open.ts = entry.ts;
open.deltas.push(delta);
}
None => {
run = Some(Run {
kind,
seq: entry.seq,
ts: entry.ts,
deltas: vec![delta],
})
}
}
}
Err(event) => {
// The run above this event (newer) is complete: it is a row, and so is this
// event.
flush(&mut run, &mut out);
out.push(SeqEvent { event, ..entry });
}
} }
} }
flush(&mut run, &mut out); flush(&mut run, &mut out);
@@ -385,6 +529,41 @@ impl<'a> Indexed<'a> {
} }
} }
/// One run of same-kind deltas being gathered by [`Indexed::parse_coalesced`].
struct Run {
kind: Streamed,
seq: u64,
ts: f64,
deltas: Vec<String>,
}
/// The event kinds that arrive a fragment at a time and are read as one row.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Streamed {
Text,
Thinking,
}
impl Streamed {
/// The kind and fragment of a streamed event, or the event back unchanged
/// when it is not one -- so the caller cannot forget to put it back.
fn of(event: Event) -> std::result::Result<(Self, String), Event> {
match event {
Event::AssistantText { delta } => Ok((Self::Text, delta)),
Event::Thinking { delta } => Ok((Self::Thinking, delta)),
other => Err(other),
}
}
/// The run put back together as the event it was a run of.
fn of_delta(self, delta: String) -> Event {
match self {
Self::Text => Event::AssistantText { delta },
Self::Thinking => Event::Thinking { delta },
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -396,6 +575,51 @@ mod tests {
} }
} }
/// The failure that took every live session down on 2026-09-06: an event
/// kind was removed from the enum after transcripts had already recorded
/// it, so every read of those files failed and the sessions in them could
/// not be opened, listed, paged or sent to.
///
/// A transcript is append-only and permanent, so **the set of kinds that
/// can appear in one only ever grows** -- what this build writes is not
/// what it may have to read. A line it cannot make sense of has to be a
/// line, not the end of the file.
#[test]
fn a_line_of_a_kind_this_build_does_not_know_does_not_break_the_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
std::fs::write(
&path,
concat!(
r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#,
"\n",
r#"{"seq":2,"ts":2.0,"type":"assistantText","delta":"hi"}"#,
"\n",
r#"{"seq":3,"ts":3.0,"type":"taskNote","about":"t","title":"h","status":"completed"}"#,
"\n",
r#"{"seq":4,"ts":4.0,"type":"somethingFromTheFuture","whatever":[1,2]}"#,
"\n",
r#"{"seq":5,"ts":5.0,"type":"status","state":"idle"}"#,
"\n",
),
)
.expect("write");
let entries = read_after(&path, 0).expect("a strange line is not a broken file");
assert_eq!(entries.len(), 5, "every line is still a line: {entries:?}");
assert_eq!(entries[4].seq, 5);
let transcript = Transcript::open(&path).expect("open");
assert_eq!(transcript.last_status(), Some(SessionStatus::Idle));
// The next seq is counted from the newest *line*, whatever kind it is.
// Skipping the ones this build cannot read would hand out a seq the
// file already contains.
assert_eq!(transcript.next_seq, 6);
let window = read_window(&path, None, None, 80, false).expect("window");
assert_eq!(window.len(), 5, "{window:?}");
}
#[test] #[test]
fn assigns_increasing_seqs_and_replays_after_a_cursor() { fn assigns_increasing_seqs_and_replays_after_a_cursor() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
@@ -416,6 +640,53 @@ mod tests {
assert!(read_after(&path, 3).expect("read").is_empty()); assert!(read_after(&path, 3).expect("read").is_empty());
} }
/// What a background task's card is drawn from and what tapping it moves
/// to. An id that was never a tool call -- or one whose call is a kind
/// this never recorded arguments for -- is simply absent, which is the
/// card with nothing to go to rather than an error.
#[test]
fn a_tool_call_is_found_by_its_id_and_a_stranger_is_not() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
let mut transcript = Transcript::open(&path).expect("open");
transcript.append(text("before"), 1.0).expect("append");
for id in ["call-one", "call-two"] {
transcript
.append(
Event::ToolStart {
id: id.to_string(),
tool: "Bash".to_string(),
input: serde_json::json!({"command": format!("run {id}")}),
},
2.0,
)
.expect("append");
}
transcript.append(text("after"), 3.0).expect("append");
let wanted = ["call-two", "never-called"]
.into_iter()
.map(str::to_string)
.collect();
let found = locate_tool_calls(&path, &wanted).expect("locate");
assert_eq!(found.len(), 1);
let call = found.get("call-two").expect("the call that was made");
assert_eq!(call.seq, 3);
assert_eq!(call.input["command"], "run call-two");
assert!(
locate_tool_calls(&path, &HashSet::new())
.expect("locate")
.is_empty()
);
let missing = dir.path().join("not-a-transcript.jsonl");
assert!(
locate_tool_calls(&missing, &wanted)
.expect("locate")
.is_empty()
);
}
#[test] #[test]
fn reopening_continues_the_numbering() { fn reopening_continues_the_numbering() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
@@ -586,6 +857,55 @@ mod tests {
); );
} }
#[test]
/// Thinking streams a fragment at a time exactly as a reply does, so a page
/// counted in rows has to coalesce it too -- otherwise one block of working
/// is a whole page of near-duplicate events. And the two runs stay two: a
/// weld across the boundary would put the model's working inside what it
/// said, in the prompt as well as on screen.
fn thinking_deltas_coalesce_into_a_row_of_their_own() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
let mut transcript = Transcript::open(&path).expect("append");
for delta in ["think", "ing"] {
transcript
.append(
Event::Thinking {
delta: delta.into(),
},
0.0,
)
.expect("append");
}
transcript
.append(Event::ThinkingDone { ms: 1200 }, 0.0)
.expect("append");
for delta in ["said", " it"] {
transcript.append(text(delta), 0.0).expect("append");
}
// `before` past the end, since coalescing is only ever done on settled
// history -- see [`read_window`].
let rows = read_window(&path, Some(6), None, 10, true).expect("window");
assert_eq!(rows.len(), 3);
assert!(matches!(
&rows[0],
SeqEvent { seq: 1, event: Event::Thinking { delta }, .. } if delta == "thinking"
));
assert!(matches!(
&rows[1],
SeqEvent {
seq: 3,
event: Event::ThinkingDone { ms: 1200 },
..
}
));
assert!(matches!(
&rows[2],
SeqEvent { seq: 4, event: Event::AssistantText { delta }, .. } if delta == "said it"
));
}
#[test] #[test]
fn a_floor_inside_a_delta_run_leaves_the_partial_run_it_cuts() { fn a_floor_inside_a_delta_run_leaves_the_partial_run_it_cuts() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
@@ -738,6 +1058,9 @@ mod tests {
attachments: Vec::new(), attachments: Vec::new(),
}, },
text("hello"), text("hello"),
Event::AssistantTextFinal {
text: "hello, revised".into(),
},
Event::ToolStart { Event::ToolStart {
id: "t1".into(), id: "t1".into(),
tool: "bash".into(), tool: "bash".into(),
@@ -767,12 +1090,18 @@ mod tests {
id: "q1".into(), id: "q1".into(),
answers: vec!["Yes".into()], answers: vec!["Yes".into()],
}, },
Event::BackgroundTasks { count: 5 },
Event::Status { Event::Status {
state: SessionStatus::Idle, state: SessionStatus::Idle,
}, },
Event::UsageDelta { Event::UsageDelta {
tokens: 42, tokens: 42,
context: Some(42), context: Some(42),
tokens_per_second: None,
prefill_ms: None,
},
Event::AuthenticationRequired {
message: "sign in again".into(),
}, },
Event::Error { Event::Error {
message: "boom".into(), message: "boom".into(),
+90 -6
View File
@@ -91,21 +91,99 @@ pub enum Streams {
} }
/// The machine a session's process runs on. /// The machine a session's process runs on.
#[derive(Clone)]
pub enum Transport { pub enum Transport {
/// The machine this server is running on. /// The machine this server is running on.
Here, Here,
/// Reached with the system `ssh` client. Owns its entry rather than /// Reached with the system `ssh` client. Owns its entry rather than
/// borrowing it, so a session keeps working against the config it was /// borrowing it, so a session keeps working against the config it was
/// spawned with even if the setup is edited afterwards. /// spawned with even if the machine is edited afterwards.
Ssh { name: String, ssh: SshConfig }, Ssh { name: String, ssh: SshConfig },
} }
impl Transport { impl Transport {
/// The transport a setup describes; a setup with no `ssh` is here. /// Exchanges newline-delimited JSON requests with a short-lived stdio
pub fn for_setup(setup: &crate::config::SetupConfig) -> Self { /// server. `initial` is written first; after its response arrives,
match &setup.ssh { /// `requests` is written and the response bearing `wanted_id` is returned.
///
/// This is the shape Codex's app-server requires for a usage read: an
/// initialize round trip must finish before the initialized notification
/// and account request are accepted.
pub fn request_json_blocking(
&self,
launch: &Launch,
initial: &serde_json::Value,
requests: &[serde_json::Value],
wanted_id: u64,
) -> Result<serde_json::Value> {
use std::io::{BufRead, BufReader, Write};
let host = match self {
Self::Here => None,
Self::Ssh { ssh, .. } => Some(ssh),
};
let mut command = crate::ssh::command(
host,
&launch.program,
&launch.args,
launch.cwd.as_deref(),
launch.forward,
);
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.with_context(|| format!("couldn't run \"{}\" {}", launch.program, self.describe()))?;
let mut stdin = child.stdin.take().context("the JSON server has no stdin")?;
let stdout = child
.stdout
.take()
.context("the JSON server has no stdout")?;
writeln!(stdin, "{initial}")?;
stdin.flush()?;
let mut reader = BufReader::new(stdout);
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
anyhow::bail!("the JSON server exited before initialization completed");
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if value.get("id").and_then(serde_json::Value::as_u64)
== initial.get("id").and_then(serde_json::Value::as_u64)
{
break;
}
}
for request in requests {
writeln!(stdin, "{request}")?;
}
stdin.flush()?;
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
anyhow::bail!("the JSON server exited before answering request {wanted_id}");
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if value.get("id").and_then(serde_json::Value::as_u64) == Some(wanted_id) {
let _ = child.kill();
let _ = child.wait();
return Ok(value);
}
}
}
/// The transport a machine describes; a machine with no `ssh` is here.
pub fn for_machine(machine: &crate::config::MachineConfig) -> Self {
match &machine.ssh {
Some(ssh) => Self::Ssh { Some(ssh) => Self::Ssh {
name: setup.name.clone(), name: machine.name.clone(),
ssh: ssh.clone(), ssh: ssh.clone(),
}, },
None => Self::Here, None => Self::Here,
@@ -174,6 +252,12 @@ impl Transport {
/// would otherwise need a runtime to ask a machine a question. Both build the /// would otherwise need a runtime to ask a machine a question. Both build the
/// invocation the same way. /// invocation the same way.
pub fn capture_blocking(&self, launch: &Launch) -> Result<String> { pub fn capture_blocking(&self, launch: &Launch) -> Result<String> {
Ok(String::from_utf8_lossy(&self.capture_bytes_blocking(launch)?).into_owned())
}
/// The byte-preserving form of [`capture_blocking`](Self::capture_blocking), used when a
/// driver copies a file back from the machine it runs on.
pub fn capture_bytes_blocking(&self, launch: &Launch) -> Result<Vec<u8>> {
let host = match self { let host = match self {
Self::Here => None, Self::Here => None,
Self::Ssh { ssh, .. } => Some(ssh), Self::Ssh { ssh, .. } => Some(ssh),
@@ -195,7 +279,7 @@ impl Transport {
stderr stderr
}); });
} }
Ok(String::from_utf8_lossy(&output.stdout).into_owned()) Ok(output.stdout)
} }
/// Runs `launch` with `input` on its stdin and reports everything it /// Runs `launch` with `input` on its stdin and reports everything it
-215
View File
@@ -1,215 +0,0 @@
//! Finding out what a machine can run, rather than being told.
//!
//! The phone adds a machine by giving connection details; this asks the machine
//! 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
//! a command from the phone.** If it did, the enrolled token could introduce
//! arbitrary programs to run on every machine a setup names.
//!
//! 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
//! on the next probe.
//!
//! The cost is that a program somewhere unusual is invisible. The escape hatch
//! is editing `config.ron` on the backend, which is exactly the authority the
//! phone is not being given.
use anyhow::Result;
use crate::config::{DriverKind, ProviderConfig};
use crate::session::transport::{Launch, Transport};
/// What is looked for, and what finding it makes. Extending this is how a new
/// driver becomes discoverable -- one row, not a branch anywhere. The name is
/// what the provider gets called, so it is what the phone shows and what a
/// session stores.
const PROBES: &[(&str, &str, DriverKind)] = &[
("claude-cli", "claude", DriverKind::ClaudeCli),
// Named for the program rather than for where it runs: it runs
// wherever the setup 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
/// screen, not a restriction -- the field stays free text.
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
/// Asks `transport`'s machine which of [`PROBES`] it has.
///
/// One round trip rather than one per program: over ssh each would be a separate
/// connection and handshake. `command -v` is POSIX and a shell builtin, so it
/// works whatever is installed -- and `|| true` keeps a missing program from
/// ending the loop, since the caller wants the whole answer.
pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect();
let script = format!(
"for p in {}; do command -v \"$p\" || true; done",
wanted.join(" ")
);
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
let found = transport.capture(&launch).await.map_err(explain)?;
let mut providers = Vec::new();
// Echo runs inside this server, so it exists exactly where this server does
// and nowhere else. Offering it on a remote machine would be a choice that
// changes nothing.
if matches!(transport, Transport::Here) {
providers.push(ProviderConfig {
name: crate::config::ECHO_PROVIDER.to_string(),
kind: DriverKind::Echo,
command: None,
models: Vec::new(),
});
}
for (name, binary, kind) in PROBES {
let path = found
.lines()
.map(str::trim)
.find(|line| line.rsplit('/').next() == Some(*binary));
let Some(path) = path else {
continue;
};
providers.push(ProviderConfig {
name: (*name).to_string(),
kind: *kind,
// The resolved path rather than the bare name: PATH under a
// non-interactive ssh session is not the one a person sees when they
// log in, so "it is on my PATH" is not enough.
command: Some(path.to_string()),
models: match kind {
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
_ => Vec::new(),
},
});
}
Ok(providers)
}
/// 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
/// exactly who is not reading this one. Host key verification is the case that
/// matters: **every** machine fails it the first time, so without this, adding a
/// machine from the phone looks broken rather than unfinished.
///
/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` stays
/// at its default, so a first connection is a decision somebody makes on the
/// backend with the key in front of them.
fn explain(err: anyhow::Error) -> anyhow::Error {
let message = format!("{err:#}");
if message.contains("Host key verification failed") {
return anyhow::anyhow!(
"{message} This machine has not been connected to before, so its key is not \
trusted yet. Ssh to it once from the backend -- that is where the decision to \
trust a key belongs -- and try again.",
);
}
if message.contains("Permission denied") {
return anyhow::anyhow!(
"{message} The key named here has to be authorized on that machine, and the path \
is read on the backend rather than on the phone.",
);
}
err
}
/// 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
/// resolved by the caller, which is the only place that knows what exists.
pub fn id_from(label: &str) -> String {
let slug: String = label
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let slug = slug.trim_matches('-').replace("--", "-");
if slug.is_empty() {
crate::session::random_hex()
} else {
slug.chars().take(32).collect()
}
}
/// Normalises what a phone keyboard produced: trims, drops blanks, and
/// expands a leading `~` the way a shell would.
pub fn tidy(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
Some(match value.strip_prefix("~/") {
Some(rest) => match std::env::home_dir() {
Some(home) => home.join(rest).to_string_lossy().into_owned(),
None => value.to_string(),
},
None => value.to_string(),
})
}
/// The inverse of [`tidy`]'s expansion: an absolute path under this machine's
/// home, written back as `~/…`, so that a working directory reads on a phone the
/// way it is written by hand.
///
/// Applied only to paths on **this** machine. `$HOME` here says nothing about
/// the home directory of a machine reached over ssh, so a remote path is stored
/// exactly as it was typed and the remote shell is what expands it.
pub fn shorten_home(path: &str) -> String {
let Some(home) = std::env::home_dir() else {
return path.to_string();
};
let home = home.to_string_lossy();
// The separator has to be part of the match, or `/home/bobby` would be read
// as a path inside `/home/bob`.
match path.strip_prefix(home.as_ref()) {
Some("") => "~".to_string(),
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
_ => path.to_string(),
}
}
/// Runs a launch to completion and returns its stdout as text.
///
/// 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
/// useful half of why a setup cannot be reached), and the output read as text
/// because every caller here is asking a question whose answer is words.
impl Transport {
pub async fn capture(&self, launch: &Launch) -> Result<String> {
let captured = self
.capture_with_input(launch, super::session::transport::Input::None)
.await?;
Ok(String::from_utf8_lossy(&captured.ok()?).into_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The two halves of a home-relative path, which have to be inverses: what
/// is stored is what the phone draws, and what the phone sends back is what
/// a process is started in.
#[test]
fn a_home_path_shortens_and_expands_back() {
let Some(home) = std::env::home_dir() else {
return;
};
let full = home.join("repos/ai-app-2");
let full = full.to_string_lossy();
assert_eq!(shorten_home(&full), "~/repos/ai-app-2");
assert_eq!(shorten_home(&home.to_string_lossy()), "~");
assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref()));
// Not a prefix match on the characters: a sibling directory whose name
// merely starts with the home directory's is not inside it.
let sibling = format!("{}-backup/notes", home.to_string_lossy());
assert_eq!(shorten_home(&sibling), sibling);
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
}
}
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more