zot/internal
patriceckhart bc9e57e884 feat(tui): smooth typewriter streaming across providers and turns
Assistant replies now visibly type out character-by-character at
a steady pace regardless of how the underlying provider chunks
its stream. Tool-using turns render their final summary in the
right place with no "written between two tool calls" duplication
and no reflow jump when the typing finishes.

Three bugs, one behaviour fix.

1) EvAssistantStart was unhandled.

   The core emits EvAssistantStart at the top of every oneTurn
   including every follow-up after a tool round-trip. The tui
   was ignoring the event, so after the first EvAssistantMessage
   closed out the tool_use message, streamOn stayed false and
   every subsequent EvTextDelta filled the streaming buffer
   invisibly. The final summary then appeared all at once when
   EvAssistantMessage fired at the end of the follow-up turn.

   handleEvent now has a case core.EvAssistantStart that resets
   the streaming buffer and flips streamOn back on, so the
   follow-up summary streams the same way the first reply does.
   EvTextDelta also sets streamOn=true as a belt-and-suspenders
   against stray delta sequences with no preceding start.

2) Oauth/subscription streaming chunks were too large.

   Anthropics api-key channel drip-streams tokens, so a 400-char
   summary arrives as ~25 small text_delta events and looks like
   a typewriter without any extra work. The oauth channel
   (anthropic-beta: oauth-2025-04-20) coalesces the same summary
   into 3-4 fat chunks of 100+ chars each, so the user sees a
   blank pane, then the whole paragraph lands in one frame.

   Introduced a streaming pacer goroutine that uncouples "what
   the provider sent us" from "what we paint on screen". Each
   EvTextDelta now appends into i.streamPending. A ticker at
   16ms drains paintPaceRate=6 runes per tick from streamPending
   into the rendered i.streaming buffer, invalidating after
   every move. Result: ~375 runes/sec typewriter pace that looks
   identical regardless of upstream chunk shape. For long
   replies the pacer can run slightly behind the model but
   drains to zero within a second of the last delta.

   When EvAssistantMessage arrives while the pacer still has
   buffered runes, the handler sets streamFlushPending=true and
   returns without clearing. The pacer finishes draining, then
   on the next empty tick clears streamFlushPending + streaming
   + streamOn in one shot. Short turns that finish before the
   pacer does anything stay on the synchronous reset path so we
   don't wait on a ticker for zero work.

   Abort paths (turn cancel, compact done, EvTurnEnd with
   StopAborted) call a new resetStreamingStateLocked helper that
   atomically clears streaming, streamPending, streamFlushPending
   and streamOn so a fresh turn never inherits leftover runes.

3) The finalised assistant message double-painted during the
   drain window.

   When EvAssistantMessage fires, the agent appends the full
   assistant message to a.messages. The tui reads the message
   list on every redraw, so the complete text appeared in the
   transcript immediately while the pacer was still spelling it
   out below. Two copies on screen, one complete, one partial -
   the complete one was what the user actually read.

   redraw() now hides i.view.Messages[-1] while
   streamFlushPending is true, so during the drain only the
   streaming overlay is visible. When the pacer clears the flag
   the overlay disappears and the finalised message returns in
   the same frame with identical vertical footprint (both use
   the same "zot" header plus the same markdown-rendered body),
   so the swap reads as the caret landing on the last rune.

4) Live tool-call overlay carried over across turns.

   While i.busy=true the view always appended every entry from
   i.toolOrder/i.toolCalls under the streaming block. After a
   tool round-trip those entries were already folded into the
   transcript as an assistant(tool_use) message plus a tool role
   message with the result, so the next turn's summary rendered
   sandwiched between the finalised tool_use block above and the
   live tool-call block below showing the same tool. The user
   saw the summary "written between two reads".

   The EvAssistantStart handler now resets i.toolCalls and
   i.toolOrder. Any tools from the previous round are entirely
   represented in the transcript at that point; the next
   EvToolUseStart repopulates the overlay for the new round.
   No more duplicate rendering.

Misc: extracted assistantMessageSideEffects so OnAssistant +
telegram mirroring fire on message arrival regardless of which
code path (sync-reset vs pacer-drain) handles the visual
transition. Also extracted the narrow duplicate-detection guard
in redraw so follow-up turns' typewriter streaming survives the
last-message-is-assistant invariant that holds across a tool
round-trip.

Tested manually with both short ("summarize this file") and
long ("read this package to understand it") flows on the oauth
channel; both now stream visibly.
2026-04-20 13:26:26 +02:00
..
agent feat(tui): smooth typewriter streaming across providers and turns 2026-04-20 13:26:26 +02:00
assets assets: refresh zot logo to cleaner pixel-art Z 2026-04-20 12:01:43 +02:00
auth feat(auth,tui): dark login pages + /logout picker 2026-04-19 20:14:22 +02:00
core feat(session): /session fork + /session tree 2026-04-20 11:10:56 +02:00
extproto feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext 2026-04-19 17:02:04 +02:00
provider perf(anthropic): fix cost double-count, tighten caching, correct catalog 2026-04-19 18:57:18 +02:00
skills perf(prompt): cut system prompt to the bone (410 -> 54 tokens) 2026-04-19 17:39:38 +02:00
tui feat(tui): /telegram connect | disconnect | status 2026-04-20 09:18:04 +02:00