detached 676b5d7

fix(tui): restore typewriter streaming for turns after tool use

The first assistant turn always streamed correctly, but any
follow-up assistant text after a tool round-trip popped in all
at once when the turn ended instead of typewriter-streaming.

Two bugs, fixed together:

1) handleEvent had no case for EvAssistantStart. The core emits
   this event at the top of every oneTurn including every
   follow-up, but the tui was ignoring it. So after the first
   EvAssistantMessage fired (for the tool_use message), streamOn
   stayed false, subsequent EvTextDelta events filled i.streaming
   but never made it on-screen because StreamingActive wasn't
   flipped back on. Added case core.EvAssistantStart that resets
   the buffer and sets streamOn=true. Belt-and-suspenders: the
   EvTextDelta case also sets streamOn=true so stray delta
   sequences without a preceding start still render.

2) The belt-and-suspenders guard in redraw() was too aggressive:
   it hid streaming whenever the last transcript message was
   assistant-role, which is always true during a follow-up turn
   (the last message is the assistant tool_use from the previous
   oneTurn). Narrowed to a strict duplicate check: only hide
   streaming when assistantText(lastMsg) == streamingBuffer,
   meaning EvAssistantMessage already promoted this exact text
   into the transcript but the next render tick hasnt flipped
   streamOn off yet. That is the only actual race this guard
   was protecting against.

Added a tiny assistantText helper (concatenate TextBlocks of a
message) to implement the dedupe check. Kept in the same file;
no new package API.
commit
676b5d75103737055147c6e9198d1839517c793a
author
patriceckhart · · 2026-04-20 10:56 UTC
parent
616eed3
signature
unsigned
refs
tag: v0.0.64
1 file · +42 -5
M internal/agent/modes/interactive.go +42 -5
@@ -528,11 +528,23 @@ func (i *Interactive) redraw() {
528528 }
529529 i.view.Streaming = i.streaming.String()
530530 i.view.StreamingActive = i.streamOn
531- // Belt and suspenders: if the transcript's last message is already an
532- // assistant message, the streaming view is stale (EvAssistantMessage may
533- // not have fired yet) — suppress it to avoid double-rendering the reply.
534- if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
535- i.view.StreamingActive = false
531+ // Guard against the narrow race where EvAssistantMessage has
532+ // just promoted a streaming reply into the transcript but a
533+ // render tick hasn't flipped streamOn off yet. Without the
534+ // guard, the same text would appear twice (once as the
535+ // in-flight streaming block, once as the last transcript
536+ // message). We detect the duplicate strictly: the last
537+ // assistant message's visible text must equal the streaming
538+ // buffer. Just matching on role is too broad — it also hides
539+ // the next round's typewriter streaming after a tool turn,
540+ // because the last transcript message is always assistant
541+ // (the tool-use block) until the follow-up summary lands.
542+ if i.streamOn && i.streaming.Len() > 0 {
543+ if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
544+ if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
545+ i.view.StreamingActive = false
546+ }
547+ }
536548 }
537549 // Live tool-call view: only shown while a turn is in flight. Once
538550 // the agent is idle, every tool call has already been folded into
@@ -2141,8 +2153,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
21412153 i.mu.Lock()
21422154 defer i.mu.Unlock()
21432155 switch e := ev.(type) {
2156+ case core.EvAssistantStart:
2157+ // Fires at the top of every oneTurn, including follow-up
2158+ // turns after tool use. Without this, the streaming buffer
2159+ // is still marked off from the previous assistant message
2160+ // and the final summary text pops in all at once instead
2161+ // of typewriter-streaming delta by delta.
2162+ i.streaming.Reset()
2163+ i.streamOn = true
21442164 case core.EvTextDelta:
21452165 i.streaming.WriteString(e.Delta)
2166+ i.streamOn = true
21462167 case core.EvAssistantMessage:
21472168 i.streaming.Reset()
21482169 i.streamOn = false
@@ -2918,3 +2939,19 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
29182939 func formatInt(n int) string {
29192940 return fmt.Sprintf("%d", n)
29202941 }
2942+
2943+// assistantText returns the concatenated text of every TextBlock in
2944+// m. Used by the streaming-view dedupe guard to tell when a live
2945+// streamed reply has already been promoted into the transcript.
2946+func assistantText(m provider.Message) string {
2947+ var sb strings.Builder
2948+ for _, c := range m.Content {
2949+ if tb, ok := c.(provider.TextBlock); ok {
2950+ if sb.Len() > 0 {
2951+ sb.WriteByte('\n')
2952+ }
2953+ sb.WriteString(tb.Text)
2954+ }
2955+ }
2956+ return sb.String()
2957+}
@@ -528,11 +528,23 @@ func (i *Interactive) redraw() {
528 }528 }
529 i.view.Streaming = i.streaming.String()529 i.view.Streaming = i.streaming.String()
530 i.view.StreamingActive = i.streamOn530 i.view.StreamingActive = i.streamOn
531- // Belt and suspenders: if the transcript's last message is already an531+ // Guard against the narrow race where EvAssistantMessage has
532- // assistant message, the streaming view is stale (EvAssistantMessage may532+ // just promoted a streaming reply into the transcript but a
533- // not have fired yet) — suppress it to avoid double-rendering the reply.533+ // render tick hasn't flipped streamOn off yet. Without the
534- if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {534+ // guard, the same text would appear twice (once as the
535- i.view.StreamingActive = false535+ // in-flight streaming block, once as the last transcript
536+ // message). We detect the duplicate strictly: the last
537+ // assistant message's visible text must equal the streaming
538+ // buffer. Just matching on role is too broad — it also hides
539+ // the next round's typewriter streaming after a tool turn,
540+ // because the last transcript message is always assistant
541+ // (the tool-use block) until the follow-up summary lands.
542+ if i.streamOn && i.streaming.Len() > 0 {
543+ if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
544+ if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
545+ i.view.StreamingActive = false
546+ }
547+ }
536 }548 }
537 // Live tool-call view: only shown while a turn is in flight. Once549 // Live tool-call view: only shown while a turn is in flight. Once
538 // the agent is idle, every tool call has already been folded into550 // the agent is idle, every tool call has already been folded into
@@ -2141,8 +2153,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
2141 i.mu.Lock()2153 i.mu.Lock()
2142 defer i.mu.Unlock()2154 defer i.mu.Unlock()
2143 switch e := ev.(type) {2155 switch e := ev.(type) {
2156+ case core.EvAssistantStart:
2157+ // Fires at the top of every oneTurn, including follow-up
2158+ // turns after tool use. Without this, the streaming buffer
2159+ // is still marked off from the previous assistant message
2160+ // and the final summary text pops in all at once instead
2161+ // of typewriter-streaming delta by delta.
2162+ i.streaming.Reset()
2163+ i.streamOn = true
2144 case core.EvTextDelta:2164 case core.EvTextDelta:
2145 i.streaming.WriteString(e.Delta)2165 i.streaming.WriteString(e.Delta)
2166+ i.streamOn = true
2146 case core.EvAssistantMessage:2167 case core.EvAssistantMessage:
2147 i.streaming.Reset()2168 i.streaming.Reset()
2148 i.streamOn = false2169 i.streamOn = false
@@ -2918,3 +2939,19 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
2918 func formatInt(n int) string {2939 func formatInt(n int) string {
2919 return fmt.Sprintf("%d", n)2940 return fmt.Sprintf("%d", n)
2920 }2941 }
2942+
2943+// assistantText returns the concatenated text of every TextBlock in
2944+// m. Used by the streaming-view dedupe guard to tell when a live
2945+// streamed reply has already been promoted into the transcript.
2946+func assistantText(m provider.Message) string {
2947+ var sb strings.Builder
2948+ for _, c := range m.Content {
2949+ if tb, ok := c.(provider.TextBlock); ok {
2950+ if sb.Len() > 0 {
2951+ sb.WriteByte('\n')
2952+ }
2953+ sb.WriteString(tb.Text)
2954+ }
2955+ }
2956+ return sb.String()
2957+}

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