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:
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 ->
|
||||
|
||||
Reference in new issue
Block a user