A model reasons in the same headings, lists and fenced code it answers in,
so plain text put rows of hashes and asterisks around the working the reader
opened the card to read. The card now draws `MarkdownText`, live while the
block is still open so a delta costs a parse of its last block rather than
of the whole reasoning, and the words answer the card's own tap through
`LocalMarkdownTap` the way a memory note's do.
A settled block is warmed with the rest of a page; an open one deliberately
is not, since warming a prefix per delta is a parse of the block per delta
held for ever. Echo's `/think` fixture is markdown now, because a fixture of
flat prose exercises none of this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three explanatory paragraphs were left on the session settings screen: what
auto-resume does, what Move costs, what changing the thinking level costs.
The last two are consequences of an action, so they are `RestartDialog` now --
the same question the machines tab already asks before it reloads a model, and
now in one file rather than private to that screen. The first is gone; the
switch beside it says what it is.
Thinking was two controls: a row of chips for a llama session, where it is a
provider-declared choice, and a picker button for a Claude session, where it
is the session's own effort. One kind of information drawn two ways, decided
by which code path the value came down. A choice is `PickerRow` in a list of
settings and `ChipGroup` on a form being filled in, and which of the two is
the screen's to say rather than the provider's.
`warnAboutRestart` went with them. It marked a control "(on restart)" and
wrote a sentence under the form, and it had nothing to mark: every session
param is `restart: false` and every model param is `restart: true`. The field
now decides whether saving a model's settings stops to ask, which is the
question it was always about.
The wait's bar keeps the status row's own margin instead of running to the
edges of the glass.
Checked on the emulator against the sandbox: an echo, a claude-cli and a
llama session, each with no paragraph left under a setting and thinking drawn
the same way in all three; "Think high?", "Move to /tmp?" and the model
settings dialog all stop to ask. ktfmt, compile, lint and the unit tests are
clean.
A field's hint had a grey line of its own between the label and the box, so a
form of a dozen settings was mostly explanation. It is the empty box's
placeholder now, where it costs no height and says the same thing to anybody
who has not typed yet.
The wait's bar was drawn in whatever width the status row had left beside its
words, which is where the context figure goes -- so a session loading a model
or reading a prompt stopped saying how full it was, at the moment somebody is
watching it fill. The bar is the whole width above the row instead, the
compaction's indeterminate one with it, and the percentage joins the status
word ("reading prompt 30%") rather than replacing the context. The row keeps
the bar's height when there is no bar: it sits above the transcript and the
box, and one that came and went would move both under the reader's thumb
every time a turn started.
Checked on the emulator against the sandbox's progress rig: /loading and
/reading both draw a full-width bar with "context unknown" still beside them,
and the settings form has no grey lines left between a label and its box.
ktfmt, compile, lint and the unit tests are clean.
Five things asked for on the phone, and one trap behind the first of them.
A llama prompt carries `<__media__>` where a picture was, and llama.cpp
pairs each marker with a decoded image when it tokenizes -- so a marker in
words nobody attached a picture to fails the turn, and then fails every
later one, since the conversation is folded out of a transcript that holds
it for good. A model saying the marker back is enough to do it. Every
message with words in it now goes through `without_marker`.
A model's `general.name` is filled in by whatever converted the file, and
`convert_hf_to_gguf.py` fills it from the directory it converted: Prism ML's
Bonsai publishes `general.name = "Hf"`, which is unique and so passed the
label cascade and told a reader nothing. A name is now used only where it
shares a word with the repo or the file it came from; one that does not
drops to the file name.
The model settings dialog and the server card said what a save would cost in
a paragraph under the control, read after the decision if at all. Both ask
instead, in the shape the rest of that screen already uses for Stop and
Delete -- and the dialog's question is asked over the edits, so Cancel comes
back to them.
A field's label was `labelMedium` in the variant colour while every setting
beside it was body text, which on one form read as two ranks of setting.
`GET /sessions/{id}`'s `transcriptFile` now carries the file's size, and the
settings screen draws it beside what this phone has cached.
Checked on the emulator against the sandbox: the labels line up, the row
says "14 kB · 14 kB cached", and both confirmations appear over a loaded
Qwen3-0.6B. `cargo test` 275 passed, clippy and fmt clean, lint clean.
The settings dialog had outgrown a dialog: it scrolled inside itself,
covered the session it is about, and had nowhere to put a second tab. It
is a screen now, drawn over the session like the file explorer so the
session under it stays composed, with the back gesture to leave it. The
second tab is ProviderScreen itself -- the same composable the machines
tab opens -- so a provider's settings have two ways in and one
implementation.
Every text field in the app goes through LabelledField: the label is a
line above the box rather than a thing floating inside it, the hint says
what leaving it blank means, and the padding is one line's worth.
Material's outlined field spends the height of three lines to hold one,
which on a form of a dozen settings is a screen and a half of scrolling.
The value's own text is unchanged -- the framing was what cost.
A session also gets a system prompt, which for llama.cpp is one entry in
the params table and no app change: it rides in front of the conversation
on every request rather than being recorded as the first thing in it, so
changing it takes effect on the next message. ParamKind::Prose is new
because a paragraph in a one-line box shows six words of itself.
The conversation on screen is a drawing of the record, and when the two
disagree -- or when something in the record is what has gone wrong -- there
was no way to see the record itself from the phone.
View raw in the session settings dialog opens the file explorer on the
transcript, which is its third caller and needed no new screen: the file
name and path in the header, a back button, and the lines as code. The
session says where the file is, because only the backend knows, and it
names this backend's machine rather than the session's -- for a remote
session those are two different filesystems. Back from the file lands in
the session's own directory, where the log and the process record are.
The paragraph under Reload goes, and both buttons move to a line of their
own: two buttons and a measurement do not fit a phone's width.
Both of its waits are measured somewhere and neither reached the phone: a
model coming off disk, which the router publishes on its event stream and
nowhere else, and a prompt being read, which the generation stream will
report when asked. A session now answers GET /sessions/{id}/progress with
{of, fraction, stage?}, one thread per router keeping the load's fraction
per model, and the session screen asks twice a second while it is drawing
a wait that has one.
Asked for rather than emitted: a load reports five times a second, and an
event is a transcript line for ever. The sample says which status it
measures, so one that outlived its wait cannot be drawn under another
word. The phone puts the bar in the status row's free width and the
percentage where the context figure sits -- a row of its own would move
the transcript every time a turn started -- and names the stage where a
model loads more than one file, because the fraction starts again for
each. /loading and /reading in an echo session are the rig.
A backgrounded command in a session's panel was a terminal glyph beside its
words. The glyph said "command" and so did the monospace face, which is the
same fact twice -- so the mark goes and the command is drawn the way every
other verbatim thing in this app is: highlighted, monospace, on the raw
surface. The glyph stays for the kinds whose description is prose, and for a
command a provider never named.
Tapping one landed the tool card against the bottom edge of the screen, since
a reversed list anchors an item by its bottom -- so a tall card arrived at its
last line. A card that fits the viewport is centred now, and one that does not
has its top put at the top, which is where reading it starts. Only for a
journey the reader asked for: a restore still puts them back exactly where
they stopped.
The panel's own close chevron is gone -- the drag, the scrim and Back all
close it -- and the count is drawn even when it is zero, so "nothing is
running" and "nobody has asked yet" stop looking identical. There is then
nothing to expand, so that heading carries no chevron either.
Checked on the emulator against the sandbox: the card centred in a
mid-transcript tap, sat at the newest end where the list clamps, and the
zero heading drew with no control.
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.
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.
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>
`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>
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>
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>
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.
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>
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>
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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>
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>
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>
A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.
On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.
Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Off by default and per session: it spends quota the moment quota exists,
with nobody watching, which is not a thing a default may decide. Switched
on from the session settings dialog, with the message it sends editable
("continue" unless something else is typed).
Running out of quota becomes a state rather than an error. The Claude
driver recognises its dialect's sentence -- `Claude AI usage limit
reached|1788546972` -- and reports `LimitReached` with the reset time it
gave; nothing above a driver matches on a string. The transcript draws it
as a divider, like a clear or a compaction.
The schedule is a plan to *ask*, never a plan to send. Both reset times
available are untrustworthy in the direction that matters -- the dialect's
is written when the turn fails, the endpoint's moves when the window does
-- so the wait ends in a question to the usage meter, and only `ok` with
no window at 100% sends anything. A window still spent reschedules to its
own reset time, which is what makes a limit that lifts late wait longer
and one that lifts early resume sooner. A meter that cannot be asked is a
longer wait too, never a send. A day after the limit was hit the wait
gives up and says so in the transcript, so a machine that can never be
asked is not retried for ever.
The schedule is persisted on the session: a five-hour window outlasts a
backend restart, and a wait forgotten across one never comes back.
Driven end to end with echo, never a real account: `/limit [minutes]`
reports the same event a real driver does and `/usage` sets what the meter
answers, deliberately separate so the two can disagree. The wait moved
from the dialect's two minutes to the meter's seven when the meter changed
its mind, and the message went out on the first check after the meter came
back under the limit.
Also makes the settings dialog scrollable, which these two controls made
necessary: at a 1.5x system font it clipped the last of them with nothing
on screen to say so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Output is about an eighth of what a session costs and thinking is nearly
all of it -- prose is ~1.5% of output tokens, measured over 27,015 requests
of this account's own transcripts -- so the level is the largest saving
available short of shortening the conversation itself.
Shaped like the working directory rather than like the model: the CLI's
only two setting control requests are `set_model` and `set_permission_mode`
(checked against the 2.1.258 binary), so `--effort` is read when the process
launches and cannot be asked of a running one. `set_session_effort` records
the level and stops the process; the next message or Start launches one that
has it. That is also why the picker is in the session settings dialog beside
Move, and not on the bar beside the model and the mode, which take effect
mid-turn.
`None` is a level in its own right -- the CLI's own default -- so the picker
can return to it, and a blank is normalized to it at the boundary rather
than stored as a level the CLI would reject.
Offered only where it means something: `DriverKind::takes_effort` reports
the capability and the phone leaves the row out entirely, rather than the
session-type branch this app does not have anywhere else. A llama session
would otherwise get a control whose only effect is stopping its process.
Verified on the emulator against the sandbox's fake CLI: the picker sets it,
the server reports it, and an echo session's dialog is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rate-limit bar answered a question about an account, and picked the
answer by machine. One machine runs echo, the Claude CLI and a local
model side by side, so every echo session on it drew the CLI's five-hour
window: a quota that session cannot spend and could never run down. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too so
the two lists cannot disagree), and the phone matches on machine *and*
provider. Nothing meters echo or llama, and nothing at all is drawn --
including while the first fetch is out, since "checking" under a session
that turns out to meter nothing is a row the screen then withdraws.
Echo gets a meter it can be *told* about instead: `/usage 42`,
`/usage 95 20`, `/usage 42 never`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. Those states cost
real quota to arrange, which is why none of them had been looked at.
And llama.cpp runs wherever a setup says, which was the last of phase 5.
`Transport::reserve_port` is the second half of what a transport is --
"run this" plus "reach this port" -- returning the port the server binds
there and the port that reaches it here, and `Launch::reaching` puts the
`-L` tunnel on the connection that already carries the command. Three
things that came out of building it:
- A forwarded launch gets a pty and every other one keeps `-T`. Killing
the ssh client ends a CLI by closing the stdin it reads; llama-server
never reads its stdin, so the same kill left it running on the far
machine with the model loaded -- one orphan per stopped session.
- The model is looked for on the machine that will serve it, at that
machine's own models directory, so `GET /setups/{id}/models` is what
the spawn screen offers rather than the backend's own downloads.
- The readiness poll watches the process, not only the port: a model
that will not load exits in a second and would otherwise have been
reported as "gave up after 300s". The failure carries the log's tail.
Exercised end to end against this VM over ssh to itself: spawn, load,
answer, outlive a backend restart, be adopted, answer again, and stop --
with both the ssh client and the far llama-server gone afterwards. The
local path, the Claude bar and the spawn screen checked on the emulator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same pass the server had, on the Kotlin side: comments restating what
the code says are gone, and the ones recording a measurement, a constraint
or an incident are kept but cut to a few lines each. 6540 comment lines to
5674, and 920 lines off the app.
Two doc comments had drifted onto the item above the one they describe --
`contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s
equivalent on the server was fixed in the previous commit. Each is back on
its own item, which is the only non-comment line this diff moves.
The comments are reflowed to the column limit at their own indentation:
several were written wide, and ktfmt re-wrapped them into lines holding a
single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the
result, which is the check.
Left alone deliberately: this codebase's remaining comment density is high
because the comments carry things the code cannot say -- what a null means,
what a number was measured against, which bug a guard exists for. Of the
238 one-line doc comments in the app, five were pure restatement of the
name and were removed; the rest each say something the signature does not.
ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass;
cargo test (127), clippy --all-targets and fmt still clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reopening a session downloaded the conversation again, every time, over
the tunnel. It now draws from a copy of what the server has already sent
and asks for one event to check that copy is still current.
Per session, under cacheDir, the server's own event lines in chunks named
for the range they cover -- so a coalesced page, whose lines do not say
what they cover, still records it. Only the contiguous run ending at the
newest chunk is served; a gap is closed by paging through it, bounded by
`after` on /transcript so the page stops where the phone's copy starts
and can therefore be kept. Nothing is derived and stored: rows are a
rendering, and a cache of them would need throwing away on every change
to the fold.
Nothing here is load-bearing. Missing, evicted, damaged or unwritable all
degrade to the cold open this screen did before, and the check before the
stream resumes -- one request, one event -- is what stops a replaced or
truncated file being spliced onto a copy of a different conversation.
What that check cannot see, a line changed mid-file with the tail intact,
is what Reload in session settings is for.
Measured on the emulator against ui-sandbox, on a 505-event session:
reopening it costs one request for one event, including scrolling the
whole conversation back; a cold open is two requests and 100 events. A
reset after falling 300 behind fetched the gap as four coalesced rows
rather than re-fetching 104 events and discarding them. Every chunk was
checked line by line against what the server says for the range its name
claims, across the reset and the gap-fill.
transcript-bench.sh, same viewport content and gestures, before and
after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the
transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms
either way. Within the emulator's noise, which is what a cache must be:
it changes what is fetched, not what is drawn.
Building it also found that the server handed out the same transcript
line two different ways. serde_json's default float parser is not
correctly rounded, so a ts written as ...0757 came back from /transcript
as ...0755 while the SSE stream sent the original -- invisible on screen,
since a ts is drawn as a relative time, and visible here only because the
cache compares a line it holds against the server's answer. Fixed with
float_roundtrip, with a test that fails the moment it is dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>