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

+130 -19
View File
@@ -388,7 +388,12 @@ impl<'a> Indexed<'a> {
}
/// 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
/// fraction of a row for a reply and a whole row for a tool call, and the
@@ -404,17 +409,22 @@ impl<'a> Indexed<'a> {
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.
let mut out: Vec<SeqEvent> = Vec::new();
// The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first.
let mut run: Option<(u64, f64, Vec<String>)> = None;
let flush = |run: &mut Option<(u64, f64, Vec<String>)>, out: &mut Vec<SeqEvent>| {
if let Some((seq, ts, mut deltas)) = run.take() {
// The run currently being gathered: which kind it is, its oldest
// seq/ts so far, and its deltas newest-first.
let mut run: Option<Run> = None;
let flush = |run: &mut Option<Run>, out: &mut Vec<SeqEvent>| {
if let Some(Run {
kind,
seq,
ts,
mut deltas,
}) = run.take()
{
deltas.reverse();
out.push(SeqEvent {
seq,
ts,
event: Event::AssistantText {
delta: deltas.concat(),
},
event: kind.of_delta(deltas.concat()),
});
}
};
@@ -428,19 +438,35 @@ impl<'a> Indexed<'a> {
}
index -= 1;
let entry = self.parse_one(index)?;
if let Event::AssistantText { delta } = entry.event {
match run {
Some((ref mut seq, ref mut ts, ref mut deltas)) => {
*seq = entry.seq;
*ts = entry.ts;
deltas.push(delta);
match Streamed::of(entry.event) {
Ok((kind, delta)) => {
// A run of a different kind ends here, whatever it was
// gathering: the two are separate rows.
if run.as_ref().is_some_and(|open| open.kind != kind) {
flush(&mut run, &mut out);
}
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],
})
}
}
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);
out.push(entry);
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);
@@ -449,6 +475,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)]
mod tests {
use super::*;
@@ -695,6 +756,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]
fn a_floor_inside_a_delta_run_leaves_the_partial_run_it_cuts() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -886,6 +996,7 @@ mod tests {
Event::UsageDelta {
tokens: 42,
context: Some(42),
tokens_per_second: None,
},
Event::AuthenticationRequired {
message: "sign in again".into(),