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

@@ -65,6 +65,41 @@ sealed class TranscriptItem {
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,
) : TranscriptItem()
/**
* The model's working before -- or between -- the things it said.
*
* Its own row rather than part of the reply, and deliberately not a [ToolRun]: a run of tool
* calls collapses into one card, and folding a model's reasoning into "Called 6 tools" would
* file it as one of them. Shut by default, like every other card that is not what was said.
*
* Three states, because two of them are not the same absence. [open] is a block still being
* thought, which is what the spinner is for. A closed one with an [ms] says how long it took; a
* closed one without is a block whose turn ended before anything said -- an interrupted reply,
* a session stopped mid-thought -- and it says so by not naming a duration rather than by
* naming a wrong one.
*/
data class ThinkingRow(
override val seq: Long,
val text: String,
val ms: Long? = null,
val open: Boolean = true,
) : TranscriptItem()
data class ToolRun(
@@ -256,7 +291,7 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
* with the seam wherever the reader happened to have paged.
*/
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later)
val (older, newer) = healSplitThinking(healSplitMessage(earlier, later))
val startedEarlier =
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
val endedLater =
@@ -314,6 +349,29 @@ private fun healSplitMessage(
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))
}
/**
* Hands the older calls at the join the name of the run they are joining.
*
@@ -403,7 +461,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// 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)
items.dropLast(1) + last.copy(text = last.text + event.delta, ts = entry.ts)
} else {
// 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.
@@ -411,13 +469,14 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
if (last is TranscriptItem.AssistantMsg)
listOf(TranscriptItem.TurnBreak(entry.seq))
else emptyList()
items + between + TranscriptItem.AssistantMsg(entry.seq, event.delta)
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)
items.dropLast(1) +
last.copy(text = event.text, replacesPrefix = true, ts = entry.ts)
} else {
val between =
if (last is TranscriptItem.AssistantMsg)
@@ -429,9 +488,23 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
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 ->
// 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
@@ -553,8 +626,16 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
items + TranscriptItem.Note(entry.seq, "[unreadable: ${event.kind}]")
// No row: see [SessionEvent.RetiredTaskNote].
is SessionEvent.RetiredTaskNote -> items
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.UsageDelta -> 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)
else -> items
}
is SessionEvent.ContextWindow -> items
}
@@ -566,9 +647,22 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
*/
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
if (sessionWorking(state)) return items
val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
if (last.settled) return items
return items.dropLast(1) + last.copy(settled = true)
// A block the turn ended in the middle of is over, however it ended. Left open it spins for
// the rest of the conversation, which says the machine is working when nothing is.
val ended = closeThinking(items) { it.copy(open = false) }
val last = ended.lastOrNull() as? TranscriptItem.AssistantMsg ?: return ended
if (last.settled) return ended
return ended.dropLast(1) + last.copy(settled = true)
}
/** [change] applied to the newest thinking block still open, if there is one. */
private fun closeThinking(
items: List<TranscriptItem>,
change: (TranscriptItem.ThinkingRow) -> TranscriptItem.ThinkingRow,
): List<TranscriptItem> {
val at = items.indexOfLast { it is TranscriptItem.ThinkingRow && it.open }
if (at < 0) return items
return items.toMutableList().apply { this[at] = change(this[at] as TranscriptItem.ThinkingRow) }
}
private fun updateTool(