A thinking block that says how long and how much

Each block reports its own round now. `reasoning_ms` was the reply's first
burst, written once, so on the fifteen-block reply GPT-OSS actually produces
only the first could claim a duration and the other fourteen said "Thought" and
nothing at all. `Generation.thinking_ms` accumulates per round and `close_step`
stamps it cumulatively, so steps.py diffs it exactly as it already diffs the
three lengths beside it.

The interval between a round's first and last reasoning delta, deliberately, not
a sum of gaps between deltas -- that would count the network's latency as the
model's thinking.

While it runs: "Thinking" with an ellipsis that types itself, and the seconds
and tokens climbing beside it. The ellipsis is a `content` keyframe, so there is
no timer to start, stop or clean up when the block is swapped away -- it stops
existing when the element does. The numbers come from a `think` frame, and
`round_thinking_ms` is written by the producer rather than computed by the
follower from a start time: a model that has stopped thinking and moved on to a
tool should show a settled number, not a clock that keeps running.

Tokens read exactly up to 200 and as `0.4k` above it, from one helper shared by
the live label and the stored one, so the two cannot drift into two conventions.
The live duration is terser than the finished one -- `6s` against `6 seconds` --
because it sits beside an animating word and changes every second, where "less
than a second" flickering into "1 second" reads as a glitch.

Checked against the real endpoint: fourteen marks carrying 919ms through
14223ms, per-block labels from "less than a second · 111" to "4 seconds · 0.5k",
and the live frames resetting each round rather than accumulating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 21:03:25 +02:00
parent 74dd19588b
commit 30ddcba787
9 changed files with 385 additions and 10 deletions
+20
View File
@@ -38,6 +38,7 @@ from lembas.services import interaction, settings_store, sse
from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import steps as steps_service
from lembas.services import tokens as tokens_service
from lembas.services import tools as tools_service
from lembas.services.agent import policy as agent_policy
from lembas.services.agent import terminal as terminal_service
@@ -855,6 +856,24 @@ def _step_html(message_id: str, step) -> str:
)
def _think_label(generation, thinking_tail: str) -> str:
"""How long this round has been thinking, and roughly how much.
This round's, not the reply's, so the live block means the same thing as the
closed blocks above it and does not change meaning the moment it settles.
The reply's total is already under the bubble, in the metrics chips.
The producer owns the number. Computing it here from a start time would
keep the clock running after the model had stopped thinking and moved on to
a tool, which is a timer rather than a measurement.
"""
return steps_service.thinking_label(
ms=generation.round_thinking_ms,
tokens=tokens_service.estimate(thinking_tail),
live=True,
)
def _ask_html(chat_id: str, pending) -> str:
"""The card asking the reader something, or nothing at all.
@@ -946,6 +965,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
# guarded, or a frame could wipe the answer.
thinking_tail, text_tail = steps_service.tail(generation)
yield sse.event("reasoning", escape_text(thinking_tail))
yield sse.event("think", escape_text(_think_label(generation, thinking_tail)))
yield sse.event("render", render_markdown(text_tail) if text_tail else "")
if generation.canvas.get("tabs"):
# Guarded on truthiness, which puts this in the