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>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-19 15:07:25 -04:00
1 parent 45f249ae91
commit bb5ac1a242
18 files changed
+900 -101

No files matched your search

@@ -283,6 +283,10 @@ fun SessionScreen(
// Which messages from other agents are open, by the seq that identifies their row. Closed by
// default, which is the rule for anything new in this transcript.
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
// Which thinking blocks are open, by the seq that identifies their row -- the same rule as a
// peer note, and shut by default like everything else in this transcript that is not what was
// said.
var expandedThinking by remember { mutableStateOf(setOf<Long>()) }
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
// note opened and scrolled past is still open on the way back.
var openMemories by remember { mutableStateOf(setOf<String>()) }
@@ -500,40 +504,35 @@ fun SessionScreen(
// clear move this as much as a turn does. See `contextAfter`.
contextTokens = contextAfter(contextTokens, entry.event)
contextLimit = contextLimitAfter(contextLimit, entry.event)
when (val event = entry.event) {
// Nothing further: what it carries was folded into the context above.
is SessionEvent.UsageDelta -> {}
else -> {
// What the session says it is set to now, which is the only thing that says it:
// picking from either menu asks, and the answer comes back here.
if (event is SessionEvent.Settings) {
event.model?.let { model = it }
event.permissionMode?.let { permissionMode = it }
}
if (event is SessionEvent.Status) {
// The event's own timestamp, so a compaction that began before this screen
// opened is timed from when it actually began -- and a compaction worth asking
// about is a long one.
compactingSince =
when {
event.state != "compacting" -> null
status == "compacting" -> compactingSince
else -> entry.ts
}
status = event.state
}
if (event is SessionEvent.BackgroundTasks && !isSubagent) {
backgroundTasks = event.count
}
if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event)
// In order, always: one late event recorded ahead of the backlog would fold a
// streamed delta into whatever row happened to be last by then.
// Read on the UI thread (the stream below marshals every frame here). This direct
// check closes the small window before the scroll observer records the gesture.
if (listState.isScrollInProgress && !atNewest) followingNewest = false
if (followingNewest && held.isEmpty()) record(entry) else held = held + entry
}
val event = entry.event
// What the session says it is set to now, which is the only thing that says it:
// picking from either menu asks, and the answer comes back here.
if (event is SessionEvent.Settings) {
event.model?.let { model = it }
event.permissionMode?.let { permissionMode = it }
}
if (event is SessionEvent.Status) {
// The event's own timestamp, so a compaction that began before this screen
// opened is timed from when it actually began -- and a compaction worth asking
// about is a long one.
compactingSince =
when {
event.state != "compacting" -> null
status == "compacting" -> compactingSince
else -> entry.ts
}
status = event.state
}
if (event is SessionEvent.BackgroundTasks && !isSubagent) {
backgroundTasks = event.count
}
if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event)
// In order, always: one late event recorded ahead of the backlog would fold a
// streamed delta into whatever row happened to be last by then.
// Read on the UI thread (the stream below marshals every frame here). This direct
// check closes the small window before the scroll observer records the gesture.
if (listState.isScrollInProgress && !atNewest) followingNewest = false
if (followingNewest && held.isEmpty()) record(entry) else held = held + entry
}
/**
@@ -733,11 +732,7 @@ fun SessionScreen(
// merges streaming text into the item before it, so replaying an older page through
// the live list would glue it onto the newest message rather than its own.
var earlier = listOf<TranscriptItem>()
older.forEach { entry ->
if (entry.event !is SessionEvent.UsageDelta) {
earlier = foldEvent(earlier, entry)
}
}
older.forEach { entry -> earlier = foldEvent(earlier, entry) }
older.first().seq to earlier
}
// A stream reset can replace the transcript while the page request is out. Its answer is a
@@ -827,11 +822,7 @@ fun SessionScreen(
suspend fun open(page: List<SeqEvent>) {
withContext(Dispatchers.IO) {
var scratch = listOf<TranscriptItem>()
page.forEach { entry ->
if (entry.event !is SessionEvent.UsageDelta) {
scratch = foldEvent(scratch, entry)
}
}
page.forEach { entry -> scratch = foldEvent(scratch, entry) }
warm(replies, scratch)
}
page.forEach { apply(it) }
@@ -1718,6 +1709,8 @@ fun SessionScreen(
) { unit ->
when (unit) {
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
is TranscriptUnit.ReplyFoot ->
ReplyFooter(unit.ts, unit.tokensPerSecond)
is TranscriptUnit.PeerHead ->
PeerHeadRow(
unit.item,
@@ -1808,18 +1801,31 @@ fun SessionScreen(
onOpenImage = ::openImage,
)
is TranscriptItem.AssistantMsg ->
// A whole assistant row is only ever the reply
// still arriving -- every settled reply is
// flattened into block units instead. Live is
// what earns its blocks a layer each while
// deltas land.
AssistantMessage(
item.text,
replies,
openNotes = openMemories,
onToggleNote = ::toggleMemory,
live = true,
)
// A whole assistant row is the reply still
// arriving -- every settled reply is flattened
// into block units instead, bar the one frame
// between a reply settling and its parse being
// warm. Live is what earns its blocks a layer
// each while deltas land.
Column {
AssistantMessage(
item.text,
replies,
openNotes = openMemories,
onToggleNote = ::toggleMemory,
live = !item.settled,
)
// The footer travels with the reply
// whichever way it is drawn, so the line
// does not appear a frame after the rest.
if (item.settled) {
Spacer(Modifier.height(2.dp))
ReplyFooter(
item.ts,
item.tokensPerSecond,
)
}
}
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
@@ -1845,6 +1851,25 @@ fun SessionScreen(
)
},
)
is TranscriptItem.ThinkingRow ->
ThinkingCard(
item = item,
expanded = item.seq in expandedThinking,
onToggle = {
toggleAnchored(
row,
closing =
item.seq in expandedThinking,
) {
expandedThinking =
if (
item.seq in expandedThinking
)
expandedThinking - item.seq
else expandedThinking + item.seq
}
},
)
is TranscriptItem.QuestionCard ->
QuestionRow(item, ::answerAll)
is TranscriptItem.ErrorMsg ->