detached bc9e57e

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.
commit
bc9e57e884444e14e77e000aa6b1aa39b1ff33b8
author
patriceckhart · · 2026-04-20 11:26 UTC
parent
676b5d7
signature
unsigned
refs
tag: v0.0.65
1 file · +192 -45
M internal/agent/modes/interactive.go +192 -45
@@ -154,21 +154,37 @@ type Interactive struct {
154154 ed *tui.Editor
155155 rend *tui.Renderer
156156
157- mu sync.Mutex
158- agent *core.Agent
159- streaming strings.Builder
160- streamOn bool
161- toolCalls map[string]*tui.ToolCallView
162- toolOrder []string
163- statusErr string
164- statusOK string
165- helpBlock []string // rendered above the chat when /help was typed
166- cumUsage provider.Usage
167- lastCtxInput int // input_tokens of the most recent turn — approximates current context size
168- busy bool
169- dirty chan struct{}
170- cancelTurn context.CancelFunc
171- scrollOffset int // rows from the bottom; 0 = pinned to latest
157+ mu sync.Mutex
158+ agent *core.Agent
159+ streaming strings.Builder // what's currently painted on screen
160+ streamOn bool
161+
162+ // streamPending is the runes buffered after each EvTextDelta that
163+ // haven't yet been promoted into `streaming` for rendering. It
164+ // exists because some provider paths (notably Anthropic via the
165+ // oauth/subscription channel) coalesce the model's output into a
166+ // few fat chunks instead of drip-streaming. Painting those fat
167+ // chunks verbatim looks like the summary "just appears". The
168+ // paintPace goroutine drains a handful of runes per tick from
169+ // this buffer into `streaming`, giving every path the same
170+ // typewriter feel regardless of upstream chunk size.
171+ streamPending []rune
172+ // streamFlushPending is set when EvAssistantMessage fires while
173+ // streamPending still has unrendered runes. The ticker flushes
174+ // them, then closes out the stream (clearing flags, resetting
175+ // buffers) so the final paint matches the on-disk message.
176+ streamFlushPending bool
177+ toolCalls map[string]*tui.ToolCallView
178+ toolOrder []string
179+ statusErr string
180+ statusOK string
181+ helpBlock []string // rendered above the chat when /help was typed
182+ cumUsage provider.Usage
183+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
184+ busy bool
185+ dirty chan struct{}
186+ cancelTurn context.CancelFunc
187+ scrollOffset int // rows from the bottom; 0 = pinned to latest
172188
173189 // Messages typed while a turn is in flight. Each is delivered as
174190 // its own follow-up turn once the current one finishes. Rendered
@@ -292,6 +308,12 @@ func (i *Interactive) Run(ctx context.Context) error {
292308 _, _ = term.Write([]byte(tui.SeqAltScreenOn))
293309 defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
294310
311+ // Streaming pacer: drains buffered text deltas at a steady rate
312+ // so typewriter feel is identical across providers regardless of
313+ // upstream chunk size. Starts here so it lives for the whole
314+ // session and exits with ctx.
315+ go i.runStreamPacer(ctx)
316+
295317 cols, rows := term.Size()
296318 i.rend.Resize(cols, rows)
297319 term.OnResize(func() {
@@ -526,6 +548,19 @@ func (i *Interactive) redraw() {
526548 } else {
527549 i.view.Messages = nil
528550 }
551+ // Pacer flush: while the streaming pacer is still draining the
552+ // buffer (i.e. EvAssistantMessage already fired but more runes
553+ // are queued), the final assistant message is already in
554+ // i.agent.Messages() in full. Painting it in the transcript
555+ // AND the streaming block at the same time shows the user the
556+ // complete text immediately — which defeats the whole pacer.
557+ // Hide the last message until the pacer catches up; once the
558+ // flush-pending latch clears, the message is revealed (the
559+ // streaming block disappears the same frame because streamOn
560+ // flips off, so the transition is seamless).
561+ if i.streamFlushPending && len(i.view.Messages) > 0 {
562+ i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
563+ }
529564 i.view.Streaming = i.streaming.String()
530565 i.view.StreamingActive = i.streamOn
531566 // Guard against the narrow race where EvAssistantMessage has
@@ -2013,8 +2048,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
20132048 summary, err := i.agent.Compact(ctx, 4, sink)
20142049 i.mu.Lock()
20152050 i.busy = false
2016- i.streamOn = false
2017- i.streaming.Reset()
2051+ i.resetStreamingStateLocked()
20182052 i.cancelTurn = nil
20192053 i.autoCompacting = false
20202054 switch {
@@ -2075,7 +2109,13 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
20752109 err := i.agent.Prompt(ctx, prompt, nil, sink)
20762110 i.mu.Lock()
20772111 i.busy = false
2078- i.streamOn = false
2112+ // Don't touch streamPending / streamFlushPending here — the
2113+ // pacer may still be draining the final deltas and needs to
2114+ // paint them even though Prompt has returned. It will reset
2115+ // streamOn on its own once the buffer empties.
2116+ if len(i.streamPending) == 0 {
2117+ i.streamOn = false
2118+ }
20792119 i.cancelTurn = nil
20802120 if err != nil && ctx.Err() == nil {
20812121 i.statusErr = err.Error()
@@ -2160,34 +2200,40 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
21602200 // and the final summary text pops in all at once instead
21612201 // of typewriter-streaming delta by delta.
21622202 i.streaming.Reset()
2203+ i.streamPending = i.streamPending[:0]
2204+ i.streamFlushPending = false
21632205 i.streamOn = true
2206+ // Clear the live tool-call overlay. Any tools from the
2207+ // previous round are now fully folded into the transcript
2208+ // (assistant tool_use block + tool role message with the
2209+ // result), so keeping them in the overlay would duplicate
2210+ // them in the view — once inside the finalised transcript
2211+ // and once below the streaming block, with the streaming
2212+ // summary sandwiched in between. The next EvToolUseStart
2213+ // will populate fresh entries for this turn's tools.
2214+ i.toolCalls = map[string]*tui.ToolCallView{}
2215+ i.toolOrder = nil
21642216 case core.EvTextDelta:
2165- i.streaming.WriteString(e.Delta)
2217+ // Buffer into streamPending; the paintPace ticker drains
2218+ // it into i.streaming a few runes at a time for a smooth
2219+ // typewriter effect independent of upstream chunk size.
2220+ i.streamPending = append(i.streamPending, []rune(e.Delta)...)
21662221 i.streamOn = true
21672222 case core.EvAssistantMessage:
2168- i.streaming.Reset()
2169- i.streamOn = false
2170- if i.cfg.OnAssistant != nil {
2171- i.cfg.OnAssistant(e.Message)
2172- }
2173- // Mirror the assistant's final visible text to the telegram
2174- // bridge when active. Only TextBlock content is forwarded;
2175- // tool_use blocks are internal. Send on a goroutine so the
2176- // network call doesn't hold the event loop lock.
2177- if i.telegramBridge != nil && i.telegramBridge.Active() {
2178- var sb strings.Builder
2179- for _, c := range e.Message.Content {
2180- if tb, ok := c.(provider.TextBlock); ok {
2181- if sb.Len() > 0 {
2182- sb.WriteString("\n")
2183- }
2184- sb.WriteString(tb.Text)
2185- }
2186- }
2187- if text := sb.String(); strings.TrimSpace(text) != "" {
2188- go i.telegramBridge.OnAssistantText(text)
2189- }
2223+ // OnAssistant + telegram mirroring always fire on message
2224+ // arrival — they read the FINAL message content, which is
2225+ // complete regardless of what's still in the pacer.
2226+ i.assistantMessageSideEffects(e.Message)
2227+ // If the pacer still has characters to drain, keep streamOn
2228+ // true and mark flush pending; the paintPace ticker will
2229+ // drain the remainder and reset streaming state when done.
2230+ // Otherwise (rare: full-replay sessions, abort paths) clear
2231+ // synchronously so a later render doesn't show stale text.
2232+ if len(i.streamPending) > 0 {
2233+ i.streamFlushPending = true
2234+ return
21902235 }
2236+ i.resetStreamingStateLocked()
21912237 case core.EvToolUseStart:
21922238 // Live streaming: pre-create the view so the user sees the
21932239 // tool call being composed in real time. Any subsequent
@@ -2257,10 +2303,11 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
22572303 }
22582304 case core.EvTurnEnd:
22592305 if e.Stop == provider.StopAborted {
2260- // Aborted turn: discard the partial streaming text (it is not
2261- // persisted in the transcript) and clear any transient error.
2262- i.streaming.Reset()
2263- i.streamOn = false
2306+ // Aborted turn: discard the partial streaming text (it
2307+ // is not persisted in the transcript) and clear any
2308+ // transient error. Also drop anything still buffered in
2309+ // the pacer so nothing keeps drawing after the cancel.
2310+ i.resetStreamingStateLocked()
22642311 i.statusErr = ""
22652312 i.statusOK = "cancelled"
22662313 return
@@ -2955,3 +3002,103 @@ func assistantText(m provider.Message) string {
29553002 }
29563003 return sb.String()
29573004 }
3005+
3006+// resetStreamingStateLocked clears every piece of streaming state
3007+// in one shot. Used by abort paths (turn cancel, compact finish,
3008+// queue drain) so the pacer doesn't keep draining stale runes from
3009+// a prior turn. Must be called with i.mu held.
3010+func (i *Interactive) resetStreamingStateLocked() {
3011+ i.streaming.Reset()
3012+ i.streamPending = i.streamPending[:0]
3013+ i.streamFlushPending = false
3014+ i.streamOn = false
3015+}
3016+
3017+// assistantMessageSideEffects runs the non-visual hooks attached to
3018+// EvAssistantMessage: the host-provided OnAssistant callback and the
3019+// telegram-bridge mirror. Called with i.mu held.
3020+//
3021+// Factored out of handleEvent because the streaming pacer may defer
3022+// visual reset until after the last buffered rune has painted, but
3023+// the callbacks themselves must fire on message arrival so
3024+// downstream observers (session persistence, telegram, cost panels)
3025+// don't wait on a UI animation to catch up.
3026+func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
3027+ if i.cfg.OnAssistant != nil {
3028+ i.cfg.OnAssistant(m)
3029+ }
3030+ if i.telegramBridge != nil && i.telegramBridge.Active() {
3031+ var sb strings.Builder
3032+ for _, c := range m.Content {
3033+ if tb, ok := c.(provider.TextBlock); ok {
3034+ if sb.Len() > 0 {
3035+ sb.WriteString("\n")
3036+ }
3037+ sb.WriteString(tb.Text)
3038+ }
3039+ }
3040+ if text := sb.String(); strings.TrimSpace(text) != "" {
3041+ go i.telegramBridge.OnAssistantText(text)
3042+ }
3043+ }
3044+}
3045+
3046+// paintPaceRate is how many runes the streaming pacer releases per
3047+// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
3048+// that a 500-rune summary finishes in ~1.3s, slow enough to look
3049+// like a human typing. Empirically matches the feel of provider
3050+// paths that already drip-stream natively.
3051+const paintPaceRate = 6
3052+
3053+// paintPaceInterval is the tick interval for the streaming pacer.
3054+// 16ms lines up with the redraw throttle so we never paint faster
3055+// than the terminal can keep up.
3056+const paintPaceInterval = 16 * time.Millisecond
3057+
3058+// runStreamPacer drains buffered deltas from streamPending into
3059+// streaming a small batch per tick, invalidating after each move.
3060+// It stops when the context cancels (tui shutdown).
3061+//
3062+// Why a pacer: providers differ wildly in how they chunk their
3063+// text_delta events. The API-key path on Anthropic emits ~30 drips
3064+// for a 400-token summary; the OAuth path can coalesce the same
3065+// summary into 3 fat chunks, visually indistinguishable from "the
3066+// whole reply just appeared". The pacer normalizes that so every
3067+// path looks the same on screen.
3068+func (i *Interactive) runStreamPacer(ctx context.Context) {
3069+ t := time.NewTicker(paintPaceInterval)
3070+ defer t.Stop()
3071+ for {
3072+ select {
3073+ case <-ctx.Done():
3074+ return
3075+ case <-t.C:
3076+ i.mu.Lock()
3077+ if len(i.streamPending) == 0 {
3078+ // EvAssistantMessage already fired but the pacer
3079+ // was still draining a tick ago. Everything is now
3080+ // painted; clear the streaming flags so the next
3081+ // redraw shows the finalised transcript message
3082+ // and hides the streaming overlay.
3083+ if i.streamFlushPending {
3084+ i.streamFlushPending = false
3085+ i.streaming.Reset()
3086+ i.streamOn = false
3087+ i.mu.Unlock()
3088+ i.invalidate()
3089+ continue
3090+ }
3091+ i.mu.Unlock()
3092+ continue
3093+ }
3094+ n := paintPaceRate
3095+ if n > len(i.streamPending) {
3096+ n = len(i.streamPending)
3097+ }
3098+ i.streaming.WriteString(string(i.streamPending[:n]))
3099+ i.streamPending = i.streamPending[n:]
3100+ i.mu.Unlock()
3101+ i.invalidate()
3102+ }
3103+ }
3104+}
@@ -154,21 +154,37 @@ type Interactive struct {
154 ed *tui.Editor154 ed *tui.Editor
155 rend *tui.Renderer155 rend *tui.Renderer
156 156
157- mu sync.Mutex157+ mu sync.Mutex
158- agent *core.Agent158+ agent *core.Agent
159- streaming strings.Builder159+ streaming strings.Builder // what's currently painted on screen
160- streamOn bool160+ streamOn bool
161- toolCalls map[string]*tui.ToolCallView161+
162- toolOrder []string162+ // streamPending is the runes buffered after each EvTextDelta that
163- statusErr string163+ // haven't yet been promoted into `streaming` for rendering. It
164- statusOK string164+ // exists because some provider paths (notably Anthropic via the
165- helpBlock []string // rendered above the chat when /help was typed165+ // oauth/subscription channel) coalesce the model's output into a
166- cumUsage provider.Usage166+ // few fat chunks instead of drip-streaming. Painting those fat
167- lastCtxInput int // input_tokens of the most recent turn — approximates current context size167+ // chunks verbatim looks like the summary "just appears". The
168- busy bool168+ // paintPace goroutine drains a handful of runes per tick from
169- dirty chan struct{}169+ // this buffer into `streaming`, giving every path the same
170- cancelTurn context.CancelFunc170+ // typewriter feel regardless of upstream chunk size.
171- scrollOffset int // rows from the bottom; 0 = pinned to latest171+ streamPending []rune
172+ // streamFlushPending is set when EvAssistantMessage fires while
173+ // streamPending still has unrendered runes. The ticker flushes
174+ // them, then closes out the stream (clearing flags, resetting
175+ // buffers) so the final paint matches the on-disk message.
176+ streamFlushPending bool
177+ toolCalls map[string]*tui.ToolCallView
178+ toolOrder []string
179+ statusErr string
180+ statusOK string
181+ helpBlock []string // rendered above the chat when /help was typed
182+ cumUsage provider.Usage
183+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
184+ busy bool
185+ dirty chan struct{}
186+ cancelTurn context.CancelFunc
187+ scrollOffset int // rows from the bottom; 0 = pinned to latest
172 188
173 // Messages typed while a turn is in flight. Each is delivered as189 // Messages typed while a turn is in flight. Each is delivered as
174 // its own follow-up turn once the current one finishes. Rendered190 // its own follow-up turn once the current one finishes. Rendered
@@ -292,6 +308,12 @@ func (i *Interactive) Run(ctx context.Context) error {
292 _, _ = term.Write([]byte(tui.SeqAltScreenOn))308 _, _ = term.Write([]byte(tui.SeqAltScreenOn))
293 defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))309 defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
294 310
311+ // Streaming pacer: drains buffered text deltas at a steady rate
312+ // so typewriter feel is identical across providers regardless of
313+ // upstream chunk size. Starts here so it lives for the whole
314+ // session and exits with ctx.
315+ go i.runStreamPacer(ctx)
316+
295 cols, rows := term.Size()317 cols, rows := term.Size()
296 i.rend.Resize(cols, rows)318 i.rend.Resize(cols, rows)
297 term.OnResize(func() {319 term.OnResize(func() {
@@ -526,6 +548,19 @@ func (i *Interactive) redraw() {
526 } else {548 } else {
527 i.view.Messages = nil549 i.view.Messages = nil
528 }550 }
551+ // Pacer flush: while the streaming pacer is still draining the
552+ // buffer (i.e. EvAssistantMessage already fired but more runes
553+ // are queued), the final assistant message is already in
554+ // i.agent.Messages() in full. Painting it in the transcript
555+ // AND the streaming block at the same time shows the user the
556+ // complete text immediately — which defeats the whole pacer.
557+ // Hide the last message until the pacer catches up; once the
558+ // flush-pending latch clears, the message is revealed (the
559+ // streaming block disappears the same frame because streamOn
560+ // flips off, so the transition is seamless).
561+ if i.streamFlushPending && len(i.view.Messages) > 0 {
562+ i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
563+ }
529 i.view.Streaming = i.streaming.String()564 i.view.Streaming = i.streaming.String()
530 i.view.StreamingActive = i.streamOn565 i.view.StreamingActive = i.streamOn
531 // Guard against the narrow race where EvAssistantMessage has566 // Guard against the narrow race where EvAssistantMessage has
@@ -2013,8 +2048,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
2013 summary, err := i.agent.Compact(ctx, 4, sink)2048 summary, err := i.agent.Compact(ctx, 4, sink)
2014 i.mu.Lock()2049 i.mu.Lock()
2015 i.busy = false2050 i.busy = false
2016- i.streamOn = false2051+ i.resetStreamingStateLocked()
2017- i.streaming.Reset()
2018 i.cancelTurn = nil2052 i.cancelTurn = nil
2019 i.autoCompacting = false2053 i.autoCompacting = false
2020 switch {2054 switch {
@@ -2075,7 +2109,13 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
2075 err := i.agent.Prompt(ctx, prompt, nil, sink)2109 err := i.agent.Prompt(ctx, prompt, nil, sink)
2076 i.mu.Lock()2110 i.mu.Lock()
2077 i.busy = false2111 i.busy = false
2078- i.streamOn = false2112+ // Don't touch streamPending / streamFlushPending here — the
2113+ // pacer may still be draining the final deltas and needs to
2114+ // paint them even though Prompt has returned. It will reset
2115+ // streamOn on its own once the buffer empties.
2116+ if len(i.streamPending) == 0 {
2117+ i.streamOn = false
2118+ }
2079 i.cancelTurn = nil2119 i.cancelTurn = nil
2080 if err != nil && ctx.Err() == nil {2120 if err != nil && ctx.Err() == nil {
2081 i.statusErr = err.Error()2121 i.statusErr = err.Error()
@@ -2160,34 +2200,40 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
2160 // and the final summary text pops in all at once instead2200 // and the final summary text pops in all at once instead
2161 // of typewriter-streaming delta by delta.2201 // of typewriter-streaming delta by delta.
2162 i.streaming.Reset()2202 i.streaming.Reset()
2203+ i.streamPending = i.streamPending[:0]
2204+ i.streamFlushPending = false
2163 i.streamOn = true2205 i.streamOn = true
2206+ // Clear the live tool-call overlay. Any tools from the
2207+ // previous round are now fully folded into the transcript
2208+ // (assistant tool_use block + tool role message with the
2209+ // result), so keeping them in the overlay would duplicate
2210+ // them in the view — once inside the finalised transcript
2211+ // and once below the streaming block, with the streaming
2212+ // summary sandwiched in between. The next EvToolUseStart
2213+ // will populate fresh entries for this turn's tools.
2214+ i.toolCalls = map[string]*tui.ToolCallView{}
2215+ i.toolOrder = nil
2164 case core.EvTextDelta:2216 case core.EvTextDelta:
2165- i.streaming.WriteString(e.Delta)2217+ // Buffer into streamPending; the paintPace ticker drains
2218+ // it into i.streaming a few runes at a time for a smooth
2219+ // typewriter effect independent of upstream chunk size.
2220+ i.streamPending = append(i.streamPending, []rune(e.Delta)...)
2166 i.streamOn = true2221 i.streamOn = true
2167 case core.EvAssistantMessage:2222 case core.EvAssistantMessage:
2168- i.streaming.Reset()2223+ // OnAssistant + telegram mirroring always fire on message
2169- i.streamOn = false2224+ // arrival — they read the FINAL message content, which is
2170- if i.cfg.OnAssistant != nil {2225+ // complete regardless of what's still in the pacer.
2171- i.cfg.OnAssistant(e.Message)2226+ i.assistantMessageSideEffects(e.Message)
2172- }2227+ // If the pacer still has characters to drain, keep streamOn
2173- // Mirror the assistant's final visible text to the telegram2228+ // true and mark flush pending; the paintPace ticker will
2174- // bridge when active. Only TextBlock content is forwarded;2229+ // drain the remainder and reset streaming state when done.
2175- // tool_use blocks are internal. Send on a goroutine so the2230+ // Otherwise (rare: full-replay sessions, abort paths) clear
2176- // network call doesn't hold the event loop lock.2231+ // synchronously so a later render doesn't show stale text.
2177- if i.telegramBridge != nil && i.telegramBridge.Active() {2232+ if len(i.streamPending) > 0 {
2178- var sb strings.Builder2233+ i.streamFlushPending = true
2179- for _, c := range e.Message.Content {2234+ return
2180- if tb, ok := c.(provider.TextBlock); ok {
2181- if sb.Len() > 0 {
2182- sb.WriteString("\n")
2183- }
2184- sb.WriteString(tb.Text)
2185- }
2186- }
2187- if text := sb.String(); strings.TrimSpace(text) != "" {
2188- go i.telegramBridge.OnAssistantText(text)
2189- }
2190 }2235 }
2236+ i.resetStreamingStateLocked()
2191 case core.EvToolUseStart:2237 case core.EvToolUseStart:
2192 // Live streaming: pre-create the view so the user sees the2238 // Live streaming: pre-create the view so the user sees the
2193 // tool call being composed in real time. Any subsequent2239 // tool call being composed in real time. Any subsequent
@@ -2257,10 +2303,11 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
2257 }2303 }
2258 case core.EvTurnEnd:2304 case core.EvTurnEnd:
2259 if e.Stop == provider.StopAborted {2305 if e.Stop == provider.StopAborted {
2260- // Aborted turn: discard the partial streaming text (it is not2306+ // Aborted turn: discard the partial streaming text (it
2261- // persisted in the transcript) and clear any transient error.2307+ // is not persisted in the transcript) and clear any
2262- i.streaming.Reset()2308+ // transient error. Also drop anything still buffered in
2263- i.streamOn = false2309+ // the pacer so nothing keeps drawing after the cancel.
2310+ i.resetStreamingStateLocked()
2264 i.statusErr = ""2311 i.statusErr = ""
2265 i.statusOK = "cancelled"2312 i.statusOK = "cancelled"
2266 return2313 return
@@ -2955,3 +3002,103 @@ func assistantText(m provider.Message) string {
2955 }3002 }
2956 return sb.String()3003 return sb.String()
2957 }3004 }
3005+
3006+// resetStreamingStateLocked clears every piece of streaming state
3007+// in one shot. Used by abort paths (turn cancel, compact finish,
3008+// queue drain) so the pacer doesn't keep draining stale runes from
3009+// a prior turn. Must be called with i.mu held.
3010+func (i *Interactive) resetStreamingStateLocked() {
3011+ i.streaming.Reset()
3012+ i.streamPending = i.streamPending[:0]
3013+ i.streamFlushPending = false
3014+ i.streamOn = false
3015+}
3016+
3017+// assistantMessageSideEffects runs the non-visual hooks attached to
3018+// EvAssistantMessage: the host-provided OnAssistant callback and the
3019+// telegram-bridge mirror. Called with i.mu held.
3020+//
3021+// Factored out of handleEvent because the streaming pacer may defer
3022+// visual reset until after the last buffered rune has painted, but
3023+// the callbacks themselves must fire on message arrival so
3024+// downstream observers (session persistence, telegram, cost panels)
3025+// don't wait on a UI animation to catch up.
3026+func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
3027+ if i.cfg.OnAssistant != nil {
3028+ i.cfg.OnAssistant(m)
3029+ }
3030+ if i.telegramBridge != nil && i.telegramBridge.Active() {
3031+ var sb strings.Builder
3032+ for _, c := range m.Content {
3033+ if tb, ok := c.(provider.TextBlock); ok {
3034+ if sb.Len() > 0 {
3035+ sb.WriteString("\n")
3036+ }
3037+ sb.WriteString(tb.Text)
3038+ }
3039+ }
3040+ if text := sb.String(); strings.TrimSpace(text) != "" {
3041+ go i.telegramBridge.OnAssistantText(text)
3042+ }
3043+ }
3044+}
3045+
3046+// paintPaceRate is how many runes the streaming pacer releases per
3047+// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
3048+// that a 500-rune summary finishes in ~1.3s, slow enough to look
3049+// like a human typing. Empirically matches the feel of provider
3050+// paths that already drip-stream natively.
3051+const paintPaceRate = 6
3052+
3053+// paintPaceInterval is the tick interval for the streaming pacer.
3054+// 16ms lines up with the redraw throttle so we never paint faster
3055+// than the terminal can keep up.
3056+const paintPaceInterval = 16 * time.Millisecond
3057+
3058+// runStreamPacer drains buffered deltas from streamPending into
3059+// streaming a small batch per tick, invalidating after each move.
3060+// It stops when the context cancels (tui shutdown).
3061+//
3062+// Why a pacer: providers differ wildly in how they chunk their
3063+// text_delta events. The API-key path on Anthropic emits ~30 drips
3064+// for a 400-token summary; the OAuth path can coalesce the same
3065+// summary into 3 fat chunks, visually indistinguishable from "the
3066+// whole reply just appeared". The pacer normalizes that so every
3067+// path looks the same on screen.
3068+func (i *Interactive) runStreamPacer(ctx context.Context) {
3069+ t := time.NewTicker(paintPaceInterval)
3070+ defer t.Stop()
3071+ for {
3072+ select {
3073+ case <-ctx.Done():
3074+ return
3075+ case <-t.C:
3076+ i.mu.Lock()
3077+ if len(i.streamPending) == 0 {
3078+ // EvAssistantMessage already fired but the pacer
3079+ // was still draining a tick ago. Everything is now
3080+ // painted; clear the streaming flags so the next
3081+ // redraw shows the finalised transcript message
3082+ // and hides the streaming overlay.
3083+ if i.streamFlushPending {
3084+ i.streamFlushPending = false
3085+ i.streaming.Reset()
3086+ i.streamOn = false
3087+ i.mu.Unlock()
3088+ i.invalidate()
3089+ continue
3090+ }
3091+ i.mu.Unlock()
3092+ continue
3093+ }
3094+ n := paintPaceRate
3095+ if n > len(i.streamPending) {
3096+ n = len(i.streamPending)
3097+ }
3098+ i.streaming.WriteString(string(i.streamPending[:n]))
3099+ i.streamPending = i.streamPending[n:]
3100+ i.mu.Unlock()
3101+ i.invalidate()
3102+ }
3103+ }
3104+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close