detached 21dd32a

fix(tui): stop duplicating streamed assistant text (#224)

* fix(tui): stop duplicating streamed assistant text

Move paced live text into a single streamPresenter that anchors the
visible transcript at stream start, so a finished reply or fast tool
result can no longer render next to its own live copy. The pacer now
catches up on large backlogs and sleeps when idle, and live markdown
renders only the unfinished tail through the same path as final
messages.

* fix(tui): derive live markdown boundaries from the renderer

Let renderMarkdownRaw report block boundaries so the live-text cache
never disagrees with the renderer (a fence-like table row could desync
them), share the assistant row wrapping helper, and release the cache
when streaming ends. Stop paced text immediately on cancellation and
agent replacement, drop the unused assistantText helper, and tighten
the stream race tests to require exactly one visible copy.
commit
21dd32a7fd49620861d37b1cd36564ac8d139885
author
bnema · · 2026-09-24 10:13 UTC
committer
GitHub · · 2026-09-24 10:13 UTC
parent
63d78c8
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
19 files · +874 -458
M packages/agent/modes/alert_test.go +8 -7
@@ -149,7 +149,7 @@ func TestMainTurnEmitsBellAfterVisibleResponse(t *testing.T) {
149149 deadline := time.Now().Add(time.Second)
150150 for time.Now().Before(deadline) {
151151 i.mu.Lock()
152- ready := i.pendingAlert != nil && !i.streamFlushPending && len(i.streamPending) == 0
152+ ready := i.pendingAlert != nil && !i.stream.Active()
153153 i.mu.Unlock()
154154 if ready {
155155 i.redraw()
@@ -166,8 +166,9 @@ func TestMainAlertWaitsForPacedText(t *testing.T) {
166166 term := &alertTestTerminal{}
167167 i := NewInteractive(InteractiveConfig{Terminal: term})
168168 i.rend = tui.NewRenderer(io.Discard)
169- i.streamPending = []rune("final answer")
170- i.streamFlushPending = true
169+ i.stream.Start(-1, 0)
170+ i.stream.Push("final answer")
171+ i.stream.Finish()
171172 i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}
172173 ctx, cancel := context.WithCancel(context.Background())
173174 defer cancel()
@@ -176,7 +177,7 @@ func TestMainAlertWaitsForPacedText(t *testing.T) {
176177 deadline := time.Now().Add(time.Second)
177178 for time.Now().Before(deadline) {
178179 i.mu.Lock()
179- ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil
180+ ready := !i.stream.Active() && i.pendingAlert != nil
180181 i.mu.Unlock()
181182 if ready {
182183 i.redraw()
@@ -188,10 +189,10 @@ func TestMainAlertWaitsForPacedText(t *testing.T) {
188189 t.Fatalf("paced alert output = %q, want BEL after final redraw", got)
189190 }
190191 i.mu.Lock()
191- on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert
192+ on, alert := i.stream.Active(), i.pendingAlert
192193 i.mu.Unlock()
193- if on || pending != 0 || alert != nil {
194- t.Fatalf("stream state after alert: on=%v pending=%d alert=%v", on, pending, alert)
194+ if on || alert != nil {
195+ t.Fatalf("stream state after alert: on=%v alert=%v", on, alert)
195196 }
196197 }
197198
@@ -149,7 +149,7 @@ func TestMainTurnEmitsBellAfterVisibleResponse(t *testing.T) {
149 deadline := time.Now().Add(time.Second)149 deadline := time.Now().Add(time.Second)
150 for time.Now().Before(deadline) {150 for time.Now().Before(deadline) {
151 i.mu.Lock()151 i.mu.Lock()
152- ready := i.pendingAlert != nil && !i.streamFlushPending && len(i.streamPending) == 0152+ ready := i.pendingAlert != nil && !i.stream.Active()
153 i.mu.Unlock()153 i.mu.Unlock()
154 if ready {154 if ready {
155 i.redraw()155 i.redraw()
@@ -166,8 +166,9 @@ func TestMainAlertWaitsForPacedText(t *testing.T) {
166 term := &alertTestTerminal{}166 term := &alertTestTerminal{}
167 i := NewInteractive(InteractiveConfig{Terminal: term})167 i := NewInteractive(InteractiveConfig{Terminal: term})
168 i.rend = tui.NewRenderer(io.Discard)168 i.rend = tui.NewRenderer(io.Discard)
169- i.streamPending = []rune("final answer")169+ i.stream.Start(-1, 0)
170- i.streamFlushPending = true170+ i.stream.Push("final answer")
171+ i.stream.Finish()
171 i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}172 i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}
172 ctx, cancel := context.WithCancel(context.Background())173 ctx, cancel := context.WithCancel(context.Background())
173 defer cancel()174 defer cancel()
@@ -176,7 +177,7 @@ func TestMainAlertWaitsForPacedText(t *testing.T) {
176 deadline := time.Now().Add(time.Second)177 deadline := time.Now().Add(time.Second)
177 for time.Now().Before(deadline) {178 for time.Now().Before(deadline) {
178 i.mu.Lock()179 i.mu.Lock()
179- ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil180+ ready := !i.stream.Active() && i.pendingAlert != nil
180 i.mu.Unlock()181 i.mu.Unlock()
181 if ready {182 if ready {
182 i.redraw()183 i.redraw()
@@ -188,10 +189,10 @@ func TestMainAlertWaitsForPacedText(t *testing.T) {
188 t.Fatalf("paced alert output = %q, want BEL after final redraw", got)189 t.Fatalf("paced alert output = %q, want BEL after final redraw", got)
189 }190 }
190 i.mu.Lock()191 i.mu.Lock()
191- on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert192+ on, alert := i.stream.Active(), i.pendingAlert
192 i.mu.Unlock()193 i.mu.Unlock()
193- if on || pending != 0 || alert != nil {194+ if on || alert != nil {
194- t.Fatalf("stream state after alert: on=%v pending=%d alert=%v", on, pending, alert)195+ t.Fatalf("stream state after alert: on=%v alert=%v", on, alert)
195 }196 }
196 }197 }
197 198
M packages/agent/modes/compact_queue_test.go +3 -4
@@ -182,11 +182,10 @@ func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T)
182182 }
183183
184184 interactive.mu.Lock()
185- streaming, pending := interactive.streaming.Len(), len(interactive.streamPending)
186- streamOn, flushPending := interactive.streamOn, interactive.streamFlushPending
185+ streamText, streamOn := interactive.stream.Text(), interactive.stream.Active()
187186 interactive.mu.Unlock()
188- if streaming != 0 || pending != 0 || streamOn || flushPending {
189- t.Fatalf("auto-compaction started with live stream state: rendered=%d pending=%d on=%t flush=%t", streaming, pending, streamOn, flushPending)
187+ if streamText != "" || streamOn {
188+ t.Fatalf("auto-compaction started with live stream state: text=%q on=%t", streamText, streamOn)
190189 }
191190
192191 close(client.releaseCompaction)
@@ -182,11 +182,10 @@ func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T)
182 }182 }
183 183
184 interactive.mu.Lock()184 interactive.mu.Lock()
185- streaming, pending := interactive.streaming.Len(), len(interactive.streamPending)185+ streamText, streamOn := interactive.stream.Text(), interactive.stream.Active()
186- streamOn, flushPending := interactive.streamOn, interactive.streamFlushPending
187 interactive.mu.Unlock()186 interactive.mu.Unlock()
188- if streaming != 0 || pending != 0 || streamOn || flushPending {187+ if streamText != "" || streamOn {
189- t.Fatalf("auto-compaction started with live stream state: rendered=%d pending=%d on=%t flush=%t", streaming, pending, streamOn, flushPending)188+ t.Fatalf("auto-compaction started with live stream state: text=%q on=%t", streamText, streamOn)
190 }189 }
191 190
192 close(client.releaseCompaction)191 close(client.releaseCompaction)
M packages/agent/modes/double_escape.go +3 -6
@@ -117,8 +117,7 @@ func (i *Interactive) canArmSessionTreeEscape() bool {
117117 return false
118118 }
119119 i.mu.Lock()
120- busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
121- i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
120+ busy := i.busy || i.stream.Active() || i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
122121 queued := len(i.queued) != 0
123122 ag := i.agent
124123 pendingFork := i.pendingFork
@@ -143,8 +142,7 @@ func (i *Interactive) canOpenSessionTree() bool {
143142 }
144143
145144 i.mu.Lock()
146- busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
147- i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
145+ busy := i.busy || i.stream.Active() || i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
148146 queued := len(i.queued) != 0
149147 ag := i.agent
150148 pendingFork := i.pendingFork
@@ -168,8 +166,7 @@ func (i *Interactive) canCommitSessionTreeSelection() bool {
168166 return false
169167 }
170168 i.mu.Lock()
171- busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
172- i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
169+ busy := i.busy || i.stream.Active() || i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
173170 queued := len(i.queued) != 0
174171 ag := i.agent
175172 i.mu.Unlock()
@@ -117,8 +117,7 @@ func (i *Interactive) canArmSessionTreeEscape() bool {
117 return false117 return false
118 }118 }
119 i.mu.Lock()119 i.mu.Lock()
120- busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||120+ busy := i.busy || i.stream.Active() || i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
121- i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
122 queued := len(i.queued) != 0121 queued := len(i.queued) != 0
123 ag := i.agent122 ag := i.agent
124 pendingFork := i.pendingFork123 pendingFork := i.pendingFork
@@ -143,8 +142,7 @@ func (i *Interactive) canOpenSessionTree() bool {
143 }142 }
144 143
145 i.mu.Lock()144 i.mu.Lock()
146- busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||145+ busy := i.busy || i.stream.Active() || i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
147- i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
148 queued := len(i.queued) != 0146 queued := len(i.queued) != 0
149 ag := i.agent147 ag := i.agent
150 pendingFork := i.pendingFork148 pendingFork := i.pendingFork
@@ -168,8 +166,7 @@ func (i *Interactive) canCommitSessionTreeSelection() bool {
168 return false166 return false
169 }167 }
170 i.mu.Lock()168 i.mu.Lock()
171- busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||169+ busy := i.busy || i.stream.Active() || i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
172- i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
173 queued := len(i.queued) != 0170 queued := len(i.queued) != 0
174 ag := i.agent171 ag := i.agent
175 i.mu.Unlock()172 i.mu.Unlock()
M packages/agent/modes/interactive.go +39 -123
@@ -4,7 +4,6 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7- "strings"
87 "sync"
98 "sync/atomic"
109 "time"
@@ -703,73 +702,48 @@ type Interactive struct {
703702 // auto-subagents. Disable only removes these owned occurrences, leaving
704703 // identical text that came from the user's base prompt untouched.
705704 managedAutoSubagentsAddenda []string
706- streaming strings.Builder // what's currently painted on screen
707- streamOn bool
708- pendingAlert *extproto.AlertRequest
709-
710- // streamPending is the runes buffered after each EvTextDelta that
711- // haven't yet been promoted into `streaming` for rendering. It
712- // exists because some provider paths (notably Anthropic via the
713- // oauth/subscription channel) coalesce the model's output into a
714- // few fat chunks instead of drip-streaming. Painting those fat
715- // chunks verbatim looks like the summary "just appears". The
716- // paintPace goroutine drains a handful of runes per tick from
717- // this buffer into `streaming`, giving every path the same
718- // typewriter feel regardless of upstream chunk size.
719- streamPending []rune
720- // streamFlushPending is set when EvAssistantMessage fires while
721- // streamPending still has unrendered runes. The ticker flushes
722- // them, then closes out the stream (clearing flags, resetting
723- // buffers) so the final paint matches the on-disk message.
724- streamFlushPending bool
725- toolCalls map[string]*tui.ToolCallView
726- toolOrder []string
705+ // stream owns the paced live assistant text and decides how much of
706+ // the transcript is visible while that text is on screen.
707+ stream streamPresenter
708+ streamWake chan struct{}
709+ pendingAlert *extproto.AlertRequest
710+ toolCalls map[string]*tui.ToolCallView
711+ toolOrder []string
727712 // toolRenderRevision is monotonic for the lifetime of the interactive
728713 // view. Per-call counters would let unrelated tools alias the same rendered
729714 // cache frame when they reach the same local revision.
730715 toolRenderRevision uint64
731- // toolGate records, per tool-call id, how many runes of paced
732- // assistant text must have drained into `streaming` before that
733- // tool block may appear. It exists to make stream ordering
734- // deterministic: a tool call can arrive from the provider while
735- // the prose that logically precedes it is still being typed out
736- // by the pacer. Without gating, the tool block would render
737- // immediately while the intro paragraph keeps filling in below
738- // it. We snapshot the total expected stream length (already
739- // streamed + still pending) at the moment the tool starts, and
740- // hold the block back until the pacer reaches it.
741- toolGate map[string]int
742- statusErr string
743- statusOK string
744- goalStatus core.GoalStatus
745- planCurrent int
746- planTotal int
747- planSnapshots map[string]core.PlanUpdate
748- planRevision uint64
749- goalRun *goalContinuationRun
750- reloadStatusSeq uint64
751- extStatuses map[string]map[string]extensionStatus
752- extWidgets map[string]map[string]extensionWidget
753- liveBlock []string // live streaming/tool progress rendered outside scrollback
754- helpBlock []string // rendered above the chat when /help was typed
755- sessionInfoBlocks []sessionInfoBlock
756- cumUsage provider.Usage
757- codexUsage codexUsageState
758- lastCtxInput int // input_tokens of the most recent turn — approximates current context size
759- busy bool
760- ctrlCExit bool
761- activity agentActivity
762- pendingIdleWork []func()
763- dirty chan struct{}
764- renderScheduler atomic.Pointer[latestFrameScheduler]
765- renderRevision atomic.Uint64
766- renderOutsideLock bool
767- modelRefresh chan modelRefreshResult
768- modelRefreshing bool
769- startupPreDone chan startupPreResult
770- cancelTurn context.CancelFunc
771- scrollOffset int // rows from the bottom; 0 = pinned to latest
772- prevScrollOffset int // last value redraw snapped against; tracks intent
716+ statusErr string
717+ statusOK string
718+ goalStatus core.GoalStatus
719+ planCurrent int
720+ planTotal int
721+ planSnapshots map[string]core.PlanUpdate
722+ planRevision uint64
723+ goalRun *goalContinuationRun
724+ reloadStatusSeq uint64
725+ extStatuses map[string]map[string]extensionStatus
726+ extWidgets map[string]map[string]extensionWidget
727+ liveBlock []string // live streaming/tool progress rendered outside scrollback
728+ helpBlock []string // rendered above the chat when /help was typed
729+ sessionInfoBlocks []sessionInfoBlock
730+ cumUsage provider.Usage
731+ codexUsage codexUsageState
732+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
733+ busy bool
734+ ctrlCExit bool
735+ activity agentActivity
736+ pendingIdleWork []func()
737+ dirty chan struct{}
738+ renderScheduler atomic.Pointer[latestFrameScheduler]
739+ renderRevision atomic.Uint64
740+ renderOutsideLock bool
741+ modelRefresh chan modelRefreshResult
742+ modelRefreshing bool
743+ startupPreDone chan startupPreResult
744+ cancelTurn context.CancelFunc
745+ scrollOffset int // rows from the bottom; 0 = pinned to latest
746+ prevScrollOffset int // last value redraw snapped against; tracks intent
773747
774748 // prevChatLen and prevChatCols track the chat buffer's size at the
775749 // last redraw so that when content grows below the user's viewport
@@ -1103,7 +1077,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
11031077 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
11041078 rend: renderer,
11051079 toolCalls: map[string]*tui.ToolCallView{},
1106- toolGate: map[string]int{},
1080+ streamWake: make(chan struct{}, 1),
11071081 dirty: make(chan struct{}, 8),
11081082 modelRefresh: make(chan modelRefreshResult, 1),
11091083 startupPreDone: make(chan startupPreResult, 1),
@@ -2279,47 +2253,12 @@ type telegramHost struct{ iv *Interactive }
22792253 // formatInt is a tiny strconv.Itoa shim; keeps the handler above
22802254 // from needing a strconv import just for one call.
22812255
2282-// assistantText returns the concatenated text of every TextBlock in
2283-// m. Used by the streaming-view dedupe guard to tell when a live
2284-// streamed reply has already been promoted into the transcript.
2285-
22862256 // resetTranscriptRenderLocked invalidates every render cache that assumes
22872257 // the previous transcript remains structurally intact. Compaction is a
22882258 // replacement, not an append: the flow renderer must repaint from the new
22892259 // transcript rather than diff it against scrollback rows from the old one.
22902260 // Must be called with i.mu held.
22912261
2292-// resetStreamingStateLocked clears every piece of streaming state
2293-// in one shot. Used by abort paths (turn cancel, compact hand-off,
2294-// queue drain) so the pacer doesn't keep draining stale runes from
2295-// a prior turn. Must be called with i.mu held.
2296-
2297-// openAllToolGatesLocked drops every pending tool gate so that any
2298-// tool registered during this turn renders unconditionally from now
2299-// on. Called when streaming finalizes (the paced text has fully
2300-// drained and `streaming` is about to reset to length 0): without
2301-// this, the gate comparison against a freshly-reset streaming buffer
2302-// would wrongly re-hide tools that had already cleared their gate.
2303-// Must be called with i.mu held.
2304-
2305-// gateToolLocked records the stream position at which a tool call may
2306-// become visible. The gate is the total length the streaming buffer
2307-// will reach once the pacer has drained everything currently queued
2308-// (already painted + still pending). Holding the tool block back
2309-// until the pacer crosses that mark guarantees the prose emitted
2310-// before the tool call finishes typing out above it, instead of the
2311-// tool block snapping in while the paragraph is still filling in.
2312-//
2313-// We only gate while text is actively streaming. If no stream is in
2314-// flight (gate 0), the tool shows immediately, which is the correct
2315-// behaviour for tool-only turns and replayed sessions. First
2316-// registration wins so a later EvToolCall can't move an existing
2317-// gate. Must be called with i.mu held.
2318-
2319-// toolGateOpenLocked reports whether the gated tool block may render
2320-// yet, i.e. the pacer has drained enough text to reach the position
2321-// recorded when the tool call arrived. Must be called with i.mu held.
2322-
23232262 // assistantMessageSideEffects runs the non-visual hooks attached to
23242263 // EvAssistantMessage: the host-provided OnAssistant callback and the
23252264 // telegram-bridge mirror. Called with i.mu held.
@@ -2329,26 +2268,3 @@ type telegramHost struct{ iv *Interactive }
23292268 // the callbacks themselves must fire on message arrival so
23302269 // downstream observers (session persistence, telegram, cost panels)
23312270 // don't wait on a UI animation to catch up.
2332-
2333-// paintPaceRate is how many runes the streaming pacer releases per
2334-// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
2335-// that a 500-rune summary finishes in ~1.3s, slow enough to look
2336-// like a human typing. Empirically matches the feel of provider
2337-// paths that already drip-stream natively.
2338-const paintPaceRate = 6
2339-
2340-// paintPaceInterval is the tick interval for the streaming pacer.
2341-// 16ms lines up with the redraw throttle so we never paint faster
2342-// than the terminal can keep up.
2343-const paintPaceInterval = 16 * time.Millisecond
2344-
2345-// runStreamPacer drains buffered deltas from streamPending into
2346-// streaming a small batch per tick, invalidating after each move.
2347-// It stops when the context cancels (tui shutdown).
2348-//
2349-// Why a pacer: providers differ wildly in how they chunk their
2350-// text_delta events. The API-key path on Anthropic emits ~30 drips
2351-// for a 400-token summary; the OAuth path can coalesce the same
2352-// summary into 3 fat chunks, visually indistinguishable from "the
2353-// whole reply just appeared". The pacer normalizes that so every
2354-// path looks the same on screen.
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7- "strings"
8 "sync"7 "sync"
9 "sync/atomic"8 "sync/atomic"
10 "time"9 "time"
@@ -703,73 +702,48 @@ type Interactive struct {
703 // auto-subagents. Disable only removes these owned occurrences, leaving702 // auto-subagents. Disable only removes these owned occurrences, leaving
704 // identical text that came from the user's base prompt untouched.703 // identical text that came from the user's base prompt untouched.
705 managedAutoSubagentsAddenda []string704 managedAutoSubagentsAddenda []string
706- streaming strings.Builder // what's currently painted on screen705+ // stream owns the paced live assistant text and decides how much of
707- streamOn bool706+ // the transcript is visible while that text is on screen.
708- pendingAlert *extproto.AlertRequest707+ stream streamPresenter
709-708+ streamWake chan struct{}
710- // streamPending is the runes buffered after each EvTextDelta that709+ pendingAlert *extproto.AlertRequest
711- // haven't yet been promoted into `streaming` for rendering. It710+ toolCalls map[string]*tui.ToolCallView
712- // exists because some provider paths (notably Anthropic via the711+ toolOrder []string
713- // oauth/subscription channel) coalesce the model's output into a
714- // few fat chunks instead of drip-streaming. Painting those fat
715- // chunks verbatim looks like the summary "just appears". The
716- // paintPace goroutine drains a handful of runes per tick from
717- // this buffer into `streaming`, giving every path the same
718- // typewriter feel regardless of upstream chunk size.
719- streamPending []rune
720- // streamFlushPending is set when EvAssistantMessage fires while
721- // streamPending still has unrendered runes. The ticker flushes
722- // them, then closes out the stream (clearing flags, resetting
723- // buffers) so the final paint matches the on-disk message.
724- streamFlushPending bool
725- toolCalls map[string]*tui.ToolCallView
726- toolOrder []string
727 // toolRenderRevision is monotonic for the lifetime of the interactive712 // toolRenderRevision is monotonic for the lifetime of the interactive
728 // view. Per-call counters would let unrelated tools alias the same rendered713 // view. Per-call counters would let unrelated tools alias the same rendered
729 // cache frame when they reach the same local revision.714 // cache frame when they reach the same local revision.
730 toolRenderRevision uint64715 toolRenderRevision uint64
731- // toolGate records, per tool-call id, how many runes of paced716+ statusErr string
732- // assistant text must have drained into `streaming` before that717+ statusOK string
733- // tool block may appear. It exists to make stream ordering718+ goalStatus core.GoalStatus
734- // deterministic: a tool call can arrive from the provider while719+ planCurrent int
735- // the prose that logically precedes it is still being typed out720+ planTotal int
736- // by the pacer. Without gating, the tool block would render721+ planSnapshots map[string]core.PlanUpdate
737- // immediately while the intro paragraph keeps filling in below722+ planRevision uint64
738- // it. We snapshot the total expected stream length (already723+ goalRun *goalContinuationRun
739- // streamed + still pending) at the moment the tool starts, and724+ reloadStatusSeq uint64
740- // hold the block back until the pacer reaches it.725+ extStatuses map[string]map[string]extensionStatus
741- toolGate map[string]int726+ extWidgets map[string]map[string]extensionWidget
742- statusErr string727+ liveBlock []string // live streaming/tool progress rendered outside scrollback
743- statusOK string728+ helpBlock []string // rendered above the chat when /help was typed
744- goalStatus core.GoalStatus729+ sessionInfoBlocks []sessionInfoBlock
745- planCurrent int730+ cumUsage provider.Usage
746- planTotal int731+ codexUsage codexUsageState
747- planSnapshots map[string]core.PlanUpdate732+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
748- planRevision uint64733+ busy bool
749- goalRun *goalContinuationRun734+ ctrlCExit bool
750- reloadStatusSeq uint64735+ activity agentActivity
751- extStatuses map[string]map[string]extensionStatus736+ pendingIdleWork []func()
752- extWidgets map[string]map[string]extensionWidget737+ dirty chan struct{}
753- liveBlock []string // live streaming/tool progress rendered outside scrollback738+ renderScheduler atomic.Pointer[latestFrameScheduler]
754- helpBlock []string // rendered above the chat when /help was typed739+ renderRevision atomic.Uint64
755- sessionInfoBlocks []sessionInfoBlock740+ renderOutsideLock bool
756- cumUsage provider.Usage741+ modelRefresh chan modelRefreshResult
757- codexUsage codexUsageState742+ modelRefreshing bool
758- lastCtxInput int // input_tokens of the most recent turn — approximates current context size743+ startupPreDone chan startupPreResult
759- busy bool744+ cancelTurn context.CancelFunc
760- ctrlCExit bool745+ scrollOffset int // rows from the bottom; 0 = pinned to latest
761- activity agentActivity746+ prevScrollOffset int // last value redraw snapped against; tracks intent
762- pendingIdleWork []func()
763- dirty chan struct{}
764- renderScheduler atomic.Pointer[latestFrameScheduler]
765- renderRevision atomic.Uint64
766- renderOutsideLock bool
767- modelRefresh chan modelRefreshResult
768- modelRefreshing bool
769- startupPreDone chan startupPreResult
770- cancelTurn context.CancelFunc
771- scrollOffset int // rows from the bottom; 0 = pinned to latest
772- prevScrollOffset int // last value redraw snapped against; tracks intent
773 747
774 // prevChatLen and prevChatCols track the chat buffer's size at the748 // prevChatLen and prevChatCols track the chat buffer's size at the
775 // last redraw so that when content grows below the user's viewport749 // last redraw so that when content grows below the user's viewport
@@ -1103,7 +1077,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
1103 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),1077 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
1104 rend: renderer,1078 rend: renderer,
1105 toolCalls: map[string]*tui.ToolCallView{},1079 toolCalls: map[string]*tui.ToolCallView{},
1106- toolGate: map[string]int{},1080+ streamWake: make(chan struct{}, 1),
1107 dirty: make(chan struct{}, 8),1081 dirty: make(chan struct{}, 8),
1108 modelRefresh: make(chan modelRefreshResult, 1),1082 modelRefresh: make(chan modelRefreshResult, 1),
1109 startupPreDone: make(chan startupPreResult, 1),1083 startupPreDone: make(chan startupPreResult, 1),
@@ -2279,47 +2253,12 @@ type telegramHost struct{ iv *Interactive }
2279 // formatInt is a tiny strconv.Itoa shim; keeps the handler above2253 // formatInt is a tiny strconv.Itoa shim; keeps the handler above
2280 // from needing a strconv import just for one call.2254 // from needing a strconv import just for one call.
2281 2255
2282-// assistantText returns the concatenated text of every TextBlock in
2283-// m. Used by the streaming-view dedupe guard to tell when a live
2284-// streamed reply has already been promoted into the transcript.
2285-
2286 // resetTranscriptRenderLocked invalidates every render cache that assumes2256 // resetTranscriptRenderLocked invalidates every render cache that assumes
2287 // the previous transcript remains structurally intact. Compaction is a2257 // the previous transcript remains structurally intact. Compaction is a
2288 // replacement, not an append: the flow renderer must repaint from the new2258 // replacement, not an append: the flow renderer must repaint from the new
2289 // transcript rather than diff it against scrollback rows from the old one.2259 // transcript rather than diff it against scrollback rows from the old one.
2290 // Must be called with i.mu held.2260 // Must be called with i.mu held.
2291 2261
2292-// resetStreamingStateLocked clears every piece of streaming state
2293-// in one shot. Used by abort paths (turn cancel, compact hand-off,
2294-// queue drain) so the pacer doesn't keep draining stale runes from
2295-// a prior turn. Must be called with i.mu held.
2296-
2297-// openAllToolGatesLocked drops every pending tool gate so that any
2298-// tool registered during this turn renders unconditionally from now
2299-// on. Called when streaming finalizes (the paced text has fully
2300-// drained and `streaming` is about to reset to length 0): without
2301-// this, the gate comparison against a freshly-reset streaming buffer
2302-// would wrongly re-hide tools that had already cleared their gate.
2303-// Must be called with i.mu held.
2304-
2305-// gateToolLocked records the stream position at which a tool call may
2306-// become visible. The gate is the total length the streaming buffer
2307-// will reach once the pacer has drained everything currently queued
2308-// (already painted + still pending). Holding the tool block back
2309-// until the pacer crosses that mark guarantees the prose emitted
2310-// before the tool call finishes typing out above it, instead of the
2311-// tool block snapping in while the paragraph is still filling in.
2312-//
2313-// We only gate while text is actively streaming. If no stream is in
2314-// flight (gate 0), the tool shows immediately, which is the correct
2315-// behaviour for tool-only turns and replayed sessions. First
2316-// registration wins so a later EvToolCall can't move an existing
2317-// gate. Must be called with i.mu held.
2318-
2319-// toolGateOpenLocked reports whether the gated tool block may render
2320-// yet, i.e. the pacer has drained enough text to reach the position
2321-// recorded when the tool call arrived. Must be called with i.mu held.
2322-
2323 // assistantMessageSideEffects runs the non-visual hooks attached to2262 // assistantMessageSideEffects runs the non-visual hooks attached to
2324 // EvAssistantMessage: the host-provided OnAssistant callback and the2263 // EvAssistantMessage: the host-provided OnAssistant callback and the
2325 // telegram-bridge mirror. Called with i.mu held.2264 // telegram-bridge mirror. Called with i.mu held.
@@ -2329,26 +2268,3 @@ type telegramHost struct{ iv *Interactive }
2329 // the callbacks themselves must fire on message arrival so2268 // the callbacks themselves must fire on message arrival so
2330 // downstream observers (session persistence, telegram, cost panels)2269 // downstream observers (session persistence, telegram, cost panels)
2331 // don't wait on a UI animation to catch up.2270 // don't wait on a UI animation to catch up.
2332-
2333-// paintPaceRate is how many runes the streaming pacer releases per
2334-// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
2335-// that a 500-rune summary finishes in ~1.3s, slow enough to look
2336-// like a human typing. Empirically matches the feel of provider
2337-// paths that already drip-stream natively.
2338-const paintPaceRate = 6
2339-
2340-// paintPaceInterval is the tick interval for the streaming pacer.
2341-// 16ms lines up with the redraw throttle so we never paint faster
2342-// than the terminal can keep up.
2343-const paintPaceInterval = 16 * time.Millisecond
2344-
2345-// runStreamPacer drains buffered deltas from streamPending into
2346-// streaming a small batch per tick, invalidating after each move.
2347-// It stops when the context cancels (tui shutdown).
2348-//
2349-// Why a pacer: providers differ wildly in how they chunk their
2350-// text_delta events. The API-key path on Anthropic emits ~30 drips
2351-// for a 400-token summary; the OAuth path can coalesce the same
2352-// summary into 3 fat chunks, visually indistinguishable from "the
2353-// whole reply just appeared". The pacer normalizes that so every
2354-// path looks the same on screen.
M packages/agent/modes/interactive_commands.go +3 -4
@@ -235,7 +235,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
235235 }
236236 i.toolCalls = map[string]*tui.ToolCallView{}
237237 i.toolOrder = nil
238- i.toolGate = map[string]int{}
238+ i.stream.Reset()
239239 i.statusErr = ""
240240 i.statusOK = ""
241241 i.helpBlock = nil
@@ -437,7 +437,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
437437 i.mu.Lock()
438438 i.toolCalls = map[string]*tui.ToolCallView{}
439439 i.toolOrder = nil
440- i.toolGate = map[string]int{}
440+ i.stream.Reset()
441441 i.helpBlock = nil
442442 i.sessionInfoBlocks = nil
443443 i.parkedTurn = 0
@@ -1022,8 +1022,7 @@ func (i *Interactive) canResumeSessionSelection() bool {
10221022 return false
10231023 }
10241024 i.mu.Lock()
1025- busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
1026- i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
1025+ busy := i.busy || i.stream.Active() || i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
10271026 queued := len(i.queued) != 0
10281027 ag := i.agent
10291028 i.mu.Unlock()
@@ -235,7 +235,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
235 }235 }
236 i.toolCalls = map[string]*tui.ToolCallView{}236 i.toolCalls = map[string]*tui.ToolCallView{}
237 i.toolOrder = nil237 i.toolOrder = nil
238- i.toolGate = map[string]int{}238+ i.stream.Reset()
239 i.statusErr = ""239 i.statusErr = ""
240 i.statusOK = ""240 i.statusOK = ""
241 i.helpBlock = nil241 i.helpBlock = nil
@@ -437,7 +437,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
437 i.mu.Lock()437 i.mu.Lock()
438 i.toolCalls = map[string]*tui.ToolCallView{}438 i.toolCalls = map[string]*tui.ToolCallView{}
439 i.toolOrder = nil439 i.toolOrder = nil
440- i.toolGate = map[string]int{}440+ i.stream.Reset()
441 i.helpBlock = nil441 i.helpBlock = nil
442 i.sessionInfoBlocks = nil442 i.sessionInfoBlocks = nil
443 i.parkedTurn = 0443 i.parkedTurn = 0
@@ -1022,8 +1022,7 @@ func (i *Interactive) canResumeSessionSelection() bool {
1022 return false1022 return false
1023 }1023 }
1024 i.mu.Lock()1024 i.mu.Lock()
1025- busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||1025+ busy := i.busy || i.stream.Active() || i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
1026- i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
1027 queued := len(i.queued) != 01026 queued := len(i.queued) != 0
1028 ag := i.agent1027 ag := i.agent
1029 i.mu.Unlock()1028 i.mu.Unlock()
M packages/agent/modes/interactive_compaction.go +3 -3
@@ -261,7 +261,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
261261 }
262262 i.statusErr = ""
263263 i.statusOK = ""
264- // Do NOT set streamOn: the summary text should not be visible
264+ // Do NOT start the stream presenter: the summary text should not be visible
265265 // in the chat while compacting. The user just sees the spinner
266266 // and can keep typing / queue prompts.
267267 i.scrollOffset = 0
@@ -299,7 +299,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
299299 // Keep busy/compacting asserted while cleanup and queue selection run.
300300 // Completion updates can arrive in this window; clearing busy before
301301 // inspecting the queues lets them race a new turn or be stranded.
302- i.resetStreamingStateLocked()
302+ i.stream.Reset()
303303 i.cancelTurn = nil
304304 i.autoCompacting = false
305305 pendingIdleWork := i.takePendingIdleWorkLocked()
@@ -354,7 +354,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
354354 i.lastCtxInput = estimateCompactedContextInput(msgs)
355355 i.toolCalls = map[string]*tui.ToolCallView{}
356356 i.toolOrder = nil
357- i.toolGate = map[string]int{}
357+ i.stream.Reset()
358358 i.resetTranscriptRenderLocked()
359359 switch {
360360 case i.continueAfterCompact:
@@ -261,7 +261,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
261 }261 }
262 i.statusErr = ""262 i.statusErr = ""
263 i.statusOK = ""263 i.statusOK = ""
264- // Do NOT set streamOn: the summary text should not be visible264+ // Do NOT start the stream presenter: the summary text should not be visible
265 // in the chat while compacting. The user just sees the spinner265 // in the chat while compacting. The user just sees the spinner
266 // and can keep typing / queue prompts.266 // and can keep typing / queue prompts.
267 i.scrollOffset = 0267 i.scrollOffset = 0
@@ -299,7 +299,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
299 // Keep busy/compacting asserted while cleanup and queue selection run.299 // Keep busy/compacting asserted while cleanup and queue selection run.
300 // Completion updates can arrive in this window; clearing busy before300 // Completion updates can arrive in this window; clearing busy before
301 // inspecting the queues lets them race a new turn or be stranded.301 // inspecting the queues lets them race a new turn or be stranded.
302- i.resetStreamingStateLocked()302+ i.stream.Reset()
303 i.cancelTurn = nil303 i.cancelTurn = nil
304 i.autoCompacting = false304 i.autoCompacting = false
305 pendingIdleWork := i.takePendingIdleWorkLocked()305 pendingIdleWork := i.takePendingIdleWorkLocked()
@@ -354,7 +354,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
354 i.lastCtxInput = estimateCompactedContextInput(msgs)354 i.lastCtxInput = estimateCompactedContextInput(msgs)
355 i.toolCalls = map[string]*tui.ToolCallView{}355 i.toolCalls = map[string]*tui.ToolCallView{}
356 i.toolOrder = nil356 i.toolOrder = nil
357- i.toolGate = map[string]int{}357+ i.stream.Reset()
358 i.resetTranscriptRenderLocked()358 i.resetTranscriptRenderLocked()
359 switch {359 switch {
360 case i.continueAfterCompact:360 case i.continueAfterCompact:
M packages/agent/modes/interactive_events.go +27 -32
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "strings"
78 "time"
@@ -57,15 +58,10 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
5758 i.activity.apply(ev)
5859 switch e := ev.(type) {
5960 case core.EvAssistantStart:
60- // Fires at the top of every oneTurn, including follow-up
61- // turns after tool use. Without this, the streaming buffer
62- // is still marked off from the previous assistant message
63- // and the final summary text pops in all at once instead
64- // of typewriter-streaming delta by delta.
65- i.streaming.Reset()
66- i.streamPending = i.streamPending[:0]
67- i.streamFlushPending = false
68- i.streamOn = true
61+ // Fires at the top of every model request, including follow-ups
62+ // after tool use. The presenter anchors the transcript here so the
63+ // reply is never shown both as live text and as a finished message.
64+ i.startStreamLocked()
6965 // Clear the live tool-call overlay. Any tools from the
7066 // previous round are now fully folded into the transcript
7167 // (assistant tool_use block + tool role message with the
@@ -76,28 +72,18 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
7672 // will populate fresh entries for this turn's tools.
7773 i.toolCalls = map[string]*tui.ToolCallView{}
7874 i.toolOrder = nil
79- i.toolGate = map[string]int{}
8075 case core.EvTextDelta:
81- // Buffer into streamPending; the paintPace ticker drains
82- // it into i.streaming a few runes at a time for a smooth
83- // typewriter effect independent of upstream chunk size.
84- i.streamPending = append(i.streamPending, []rune(e.Delta)...)
85- i.streamOn = true
76+ // The pacer paints buffered text a few runes per tick so every
77+ // provider has the same typewriter feel regardless of chunk size.
78+ i.stream.Push(e.Delta)
79+ i.wakeStreamPacer()
8680 case core.EvAssistantMessage:
87- // OnAssistant + telegram mirroring always fire on message
88- // arrival — they read the FINAL message content, which is
89- // complete regardless of what's still in the pacer.
81+ // Side effects read the final message, which is complete even
82+ // while the pacer is still painting it.
9083 i.assistantMessageSideEffects(e.Message)
91- // If the pacer still has characters to drain, keep streamOn
92- // true and mark flush pending; the paintPace ticker will
93- // drain the remainder and reset streaming state when done.
94- // Otherwise (rare: full-replay sessions, abort paths) clear
95- // synchronously so a later render doesn't show stale text.
96- if len(i.streamPending) > 0 {
97- i.streamFlushPending = true
98- return
99- }
100- i.resetStreamingStateLocked()
84+ // The presenter keeps the live text until the last rune is painted,
85+ // then reveals the transcript in the same frame.
86+ i.stream.Finish()
10187 case core.EvToolUseStart:
10288 // Live streaming: pre-create the view so the user sees the
10389 // tool call being composed in real time. Any subsequent
@@ -112,7 +98,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
11298 i.bumpToolRevisionLocked(tc)
11399 i.toolCalls[e.ID] = tc
114100 i.toolOrder = append(i.toolOrder, e.ID)
115- i.gateToolLocked(e.ID)
101+ i.stream.Gate(e.ID)
116102 }
117103 case core.EvToolUseArgs:
118104 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -153,7 +139,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
153139 i.bumpToolRevisionLocked(tc)
154140 i.toolCalls[e.ID] = tc
155141 i.toolOrder = append(i.toolOrder, e.ID)
156- i.gateToolLocked(e.ID)
142+ i.stream.Gate(e.ID)
157143 }
158144 case core.EvToolResult:
159145 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -232,12 +218,19 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
232218 i.statusOK = ""
233219 }
234220 case core.EvTurnEnd:
235- if e.Stop == provider.StopAborted {
236- i.resetStreamingStateLocked()
221+ // Cancellation usually surfaces as a context error rather than
222+ // StopAborted. Either way, stop typing at once: text that never
223+ // reached the transcript must not keep painting and then vanish.
224+ if e.Stop == provider.StopAborted || errors.Is(e.Err, context.Canceled) {
225+ i.stream.Reset()
237226 i.statusErr = ""
238227 i.statusOK = "cancelled"
239228 return
240229 }
230+ // A request can end without an assistant message (empty reply,
231+ // extension suppression, provider error). Close the stream so the
232+ // transcript anchor never outlives its request.
233+ i.stream.Finish()
241234 if e.Stop == provider.StopLength {
242235 // The model hit its output-token cap mid-response, so the
243236 // reply (often a long write/edit) is truncated. Surface it
@@ -298,6 +291,8 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool
298291 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
299292 // This runs inside the replacement commit, never during candidate builds.
300293 i.resetCodexUsageLocked()
294+ // A draining stream is anchored in the old agent's transcript.
295+ i.stream.Reset()
301296 if ag == nil {
302297 i.setWebSearchAvailable(false)
303298 return
@@ -2,6 +2,7 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "strings"7 "strings"
7 "time"8 "time"
@@ -57,15 +58,10 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
57 i.activity.apply(ev)58 i.activity.apply(ev)
58 switch e := ev.(type) {59 switch e := ev.(type) {
59 case core.EvAssistantStart:60 case core.EvAssistantStart:
60- // Fires at the top of every oneTurn, including follow-up61+ // Fires at the top of every model request, including follow-ups
61- // turns after tool use. Without this, the streaming buffer62+ // after tool use. The presenter anchors the transcript here so the
62- // is still marked off from the previous assistant message63+ // reply is never shown both as live text and as a finished message.
63- // and the final summary text pops in all at once instead64+ i.startStreamLocked()
64- // of typewriter-streaming delta by delta.
65- i.streaming.Reset()
66- i.streamPending = i.streamPending[:0]
67- i.streamFlushPending = false
68- i.streamOn = true
69 // Clear the live tool-call overlay. Any tools from the65 // Clear the live tool-call overlay. Any tools from the
70 // previous round are now fully folded into the transcript66 // previous round are now fully folded into the transcript
71 // (assistant tool_use block + tool role message with the67 // (assistant tool_use block + tool role message with the
@@ -76,28 +72,18 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
76 // will populate fresh entries for this turn's tools.72 // will populate fresh entries for this turn's tools.
77 i.toolCalls = map[string]*tui.ToolCallView{}73 i.toolCalls = map[string]*tui.ToolCallView{}
78 i.toolOrder = nil74 i.toolOrder = nil
79- i.toolGate = map[string]int{}
80 case core.EvTextDelta:75 case core.EvTextDelta:
81- // Buffer into streamPending; the paintPace ticker drains76+ // The pacer paints buffered text a few runes per tick so every
82- // it into i.streaming a few runes at a time for a smooth77+ // provider has the same typewriter feel regardless of chunk size.
83- // typewriter effect independent of upstream chunk size.78+ i.stream.Push(e.Delta)
84- i.streamPending = append(i.streamPending, []rune(e.Delta)...)79+ i.wakeStreamPacer()
85- i.streamOn = true
86 case core.EvAssistantMessage:80 case core.EvAssistantMessage:
87- // OnAssistant + telegram mirroring always fire on message81+ // Side effects read the final message, which is complete even
88- // arrival — they read the FINAL message content, which is82+ // while the pacer is still painting it.
89- // complete regardless of what's still in the pacer.
90 i.assistantMessageSideEffects(e.Message)83 i.assistantMessageSideEffects(e.Message)
91- // If the pacer still has characters to drain, keep streamOn84+ // The presenter keeps the live text until the last rune is painted,
92- // true and mark flush pending; the paintPace ticker will85+ // then reveals the transcript in the same frame.
93- // drain the remainder and reset streaming state when done.86+ i.stream.Finish()
94- // Otherwise (rare: full-replay sessions, abort paths) clear
95- // synchronously so a later render doesn't show stale text.
96- if len(i.streamPending) > 0 {
97- i.streamFlushPending = true
98- return
99- }
100- i.resetStreamingStateLocked()
101 case core.EvToolUseStart:87 case core.EvToolUseStart:
102 // Live streaming: pre-create the view so the user sees the88 // Live streaming: pre-create the view so the user sees the
103 // tool call being composed in real time. Any subsequent89 // tool call being composed in real time. Any subsequent
@@ -112,7 +98,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
112 i.bumpToolRevisionLocked(tc)98 i.bumpToolRevisionLocked(tc)
113 i.toolCalls[e.ID] = tc99 i.toolCalls[e.ID] = tc
114 i.toolOrder = append(i.toolOrder, e.ID)100 i.toolOrder = append(i.toolOrder, e.ID)
115- i.gateToolLocked(e.ID)101+ i.stream.Gate(e.ID)
116 }102 }
117 case core.EvToolUseArgs:103 case core.EvToolUseArgs:
118 if tc, ok := i.toolCalls[e.ID]; ok {104 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -153,7 +139,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
153 i.bumpToolRevisionLocked(tc)139 i.bumpToolRevisionLocked(tc)
154 i.toolCalls[e.ID] = tc140 i.toolCalls[e.ID] = tc
155 i.toolOrder = append(i.toolOrder, e.ID)141 i.toolOrder = append(i.toolOrder, e.ID)
156- i.gateToolLocked(e.ID)142+ i.stream.Gate(e.ID)
157 }143 }
158 case core.EvToolResult:144 case core.EvToolResult:
159 if tc, ok := i.toolCalls[e.ID]; ok {145 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -232,12 +218,19 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
232 i.statusOK = ""218 i.statusOK = ""
233 }219 }
234 case core.EvTurnEnd:220 case core.EvTurnEnd:
235- if e.Stop == provider.StopAborted {221+ // Cancellation usually surfaces as a context error rather than
236- i.resetStreamingStateLocked()222+ // StopAborted. Either way, stop typing at once: text that never
223+ // reached the transcript must not keep painting and then vanish.
224+ if e.Stop == provider.StopAborted || errors.Is(e.Err, context.Canceled) {
225+ i.stream.Reset()
237 i.statusErr = ""226 i.statusErr = ""
238 i.statusOK = "cancelled"227 i.statusOK = "cancelled"
239 return228 return
240 }229 }
230+ // A request can end without an assistant message (empty reply,
231+ // extension suppression, provider error). Close the stream so the
232+ // transcript anchor never outlives its request.
233+ i.stream.Finish()
241 if e.Stop == provider.StopLength {234 if e.Stop == provider.StopLength {
242 // The model hit its output-token cap mid-response, so the235 // The model hit its output-token cap mid-response, so the
243 // reply (often a long write/edit) is truncated. Surface it236 // reply (often a long write/edit) is truncated. Surface it
@@ -298,6 +291,8 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool
298 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {291 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
299 // This runs inside the replacement commit, never during candidate builds.292 // This runs inside the replacement commit, never during candidate builds.
300 i.resetCodexUsageLocked()293 i.resetCodexUsageLocked()
294+ // A draining stream is anchored in the old agent's transcript.
295+ i.stream.Reset()
301 if ag == nil {296 if ag == nil {
302 i.setWebSearchAvailable(false)297 i.setWebSearchAvailable(false)
303 return298 return
M packages/agent/modes/interactive_host.go +2 -2
@@ -118,7 +118,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid
118118 i.view.InvalidateRenderCache()
119119 i.toolCalls = map[string]*tui.ToolCallView{}
120120 i.toolOrder = nil
121- i.toolGate = map[string]int{}
121+ i.stream.Reset()
122122 i.helpBlock = nil
123123 i.sessionInfoBlocks = nil
124124 i.extNotes = nil
@@ -203,7 +203,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
203203 i.writeTerminalTitleLocked("")
204204 i.toolCalls = map[string]*tui.ToolCallView{}
205205 i.toolOrder = nil
206- i.toolGate = map[string]int{}
206+ i.stream.Reset()
207207 i.helpBlock = nil
208208 i.sessionInfoBlocks = nil
209209 i.parkedTurn = 0
@@ -118,7 +118,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid
118 i.view.InvalidateRenderCache()118 i.view.InvalidateRenderCache()
119 i.toolCalls = map[string]*tui.ToolCallView{}119 i.toolCalls = map[string]*tui.ToolCallView{}
120 i.toolOrder = nil120 i.toolOrder = nil
121- i.toolGate = map[string]int{}121+ i.stream.Reset()
122 i.helpBlock = nil122 i.helpBlock = nil
123 i.sessionInfoBlocks = nil123 i.sessionInfoBlocks = nil
124 i.extNotes = nil124 i.extNotes = nil
@@ -203,7 +203,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
203 i.writeTerminalTitleLocked("")203 i.writeTerminalTitleLocked("")
204 i.toolCalls = map[string]*tui.ToolCallView{}204 i.toolCalls = map[string]*tui.ToolCallView{}
205 i.toolOrder = nil205 i.toolOrder = nil
206- i.toolGate = map[string]int{}206+ i.stream.Reset()
207 i.helpBlock = nil207 i.helpBlock = nil
208 i.sessionInfoBlocks = nil208 i.sessionInfoBlocks = nil
209 i.parkedTurn = 0209 i.parkedTurn = 0
M packages/agent/modes/interactive_render_test.go +24 -34
@@ -66,11 +66,7 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
6666 }
6767
6868 func TestToolRenderRevisionsAreGloballyMonotonic(t *testing.T) {
69- i := &Interactive{
70- dirty: make(chan struct{}, 1),
71- toolCalls: make(map[string]*tui.ToolCallView),
72- toolGate: make(map[string]int),
73- }
69+ i := &Interactive{dirty: make(chan struct{}, 1), toolCalls: make(map[string]*tui.ToolCallView)}
7470
7571 i.handleEventForPresentation(core.EvToolUseStart{ID: "alpha", Name: "edit"})
7672 alphaStart := i.toolCalls["alpha"].Revision
@@ -89,11 +85,7 @@ func TestToolRenderRevisionsAreGloballyMonotonic(t *testing.T) {
8985
9086 func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) {
9187 scheduler := newLatestFrameScheduler()
92- i := &Interactive{
93- dirty: make(chan struct{}, 1),
94- toolCalls: make(map[string]*tui.ToolCallView),
95- toolGate: make(map[string]int),
96- }
88+ i := &Interactive{dirty: make(chan struct{}, 1), toolCalls: make(map[string]*tui.ToolCallView)}
9789 i.renderScheduler.Store(scheduler)
9890
9991 i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"})
@@ -143,33 +135,35 @@ func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
143135 const finalText = "final message revealed after paced flush"
144136
145137 agent := &core.Agent{}
146- agent.SetMessages([]provider.Message{
147- {
148- Role: provider.RoleUser,
149- Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
150- },
151- {
152- Role: provider.RoleAssistant,
153- Content: []provider.Content{provider.TextBlock{Text: finalText}},
154- },
155- })
138+ agent.SetMessages([]provider.Message{{
139+ Role: provider.RoleUser,
140+ Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
141+ }})
156142 i := &Interactive{
157- agent: agent,
158- view: &tui.View{Theme: tui.Dark},
159- renderOutsideLock: true,
160- streamFlushPending: true,
143+ agent: agent,
144+ view: &tui.View{Theme: tui.Dark},
145+ renderOutsideLock: true,
161146 }
162147
163- revision := agent.Revision()
148+ i.mu.Lock()
149+ i.startStreamLocked()
150+ i.stream.Push(finalText)
151+ i.stream.Finish()
152+ i.mu.Unlock()
153+ // The agent appends the finished reply while the pacer is still busy.
154+ agent.SetMessages(append(agent.Messages(), provider.Message{
155+ Role: provider.RoleAssistant,
156+ Content: []provider.Content{provider.TextBlock{Text: finalText}},
157+ }))
158+
164159 i.mu.Lock()
165160 before := strings.Join(i.cachedChatLocked(80), "\n")
166- i.streamFlushPending = false
161+ for i.stream.Active() {
162+ i.stream.Tick()
163+ }
167164 after := strings.Join(i.cachedChatLocked(80), "\n")
168165 i.mu.Unlock()
169166
170- if got := agent.Revision(); got != revision {
171- t.Fatalf("stream flush changed the transcript revision: got %d, want %d", got, revision)
172- }
173167 if strings.Contains(before, finalText) {
174168 t.Fatal("stable cache revealed the final message while stream flush was pending")
175169 }
@@ -179,11 +173,7 @@ func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
179173 }
180174
181175 func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
182- i := &Interactive{
183- dirty: make(chan struct{}, 1),
184- toolCalls: make(map[string]*tui.ToolCallView),
185- toolGate: make(map[string]int),
186- }
176+ i := &Interactive{dirty: make(chan struct{}, 1), toolCalls: make(map[string]*tui.ToolCallView)}
187177 i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
188178 select {
189179 case <-i.dirty:
@@ -66,11 +66,7 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
66 }66 }
67 67
68 func TestToolRenderRevisionsAreGloballyMonotonic(t *testing.T) {68 func TestToolRenderRevisionsAreGloballyMonotonic(t *testing.T) {
69- i := &Interactive{69+ i := &Interactive{dirty: make(chan struct{}, 1), toolCalls: make(map[string]*tui.ToolCallView)}
70- dirty: make(chan struct{}, 1),
71- toolCalls: make(map[string]*tui.ToolCallView),
72- toolGate: make(map[string]int),
73- }
74 70
75 i.handleEventForPresentation(core.EvToolUseStart{ID: "alpha", Name: "edit"})71 i.handleEventForPresentation(core.EvToolUseStart{ID: "alpha", Name: "edit"})
76 alphaStart := i.toolCalls["alpha"].Revision72 alphaStart := i.toolCalls["alpha"].Revision
@@ -89,11 +85,7 @@ func TestToolRenderRevisionsAreGloballyMonotonic(t *testing.T) {
89 85
90 func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) {86 func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) {
91 scheduler := newLatestFrameScheduler()87 scheduler := newLatestFrameScheduler()
92- i := &Interactive{88+ i := &Interactive{dirty: make(chan struct{}, 1), toolCalls: make(map[string]*tui.ToolCallView)}
93- dirty: make(chan struct{}, 1),
94- toolCalls: make(map[string]*tui.ToolCallView),
95- toolGate: make(map[string]int),
96- }
97 i.renderScheduler.Store(scheduler)89 i.renderScheduler.Store(scheduler)
98 90
99 i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"})91 i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"})
@@ -143,33 +135,35 @@ func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
143 const finalText = "final message revealed after paced flush"135 const finalText = "final message revealed after paced flush"
144 136
145 agent := &core.Agent{}137 agent := &core.Agent{}
146- agent.SetMessages([]provider.Message{138+ agent.SetMessages([]provider.Message{{
147- {139+ Role: provider.RoleUser,
148- Role: provider.RoleUser,140+ Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
149- Content: []provider.Content{provider.TextBlock{Text: "prompt"}},141+ }})
150- },
151- {
152- Role: provider.RoleAssistant,
153- Content: []provider.Content{provider.TextBlock{Text: finalText}},
154- },
155- })
156 i := &Interactive{142 i := &Interactive{
157- agent: agent,143+ agent: agent,
158- view: &tui.View{Theme: tui.Dark},144+ view: &tui.View{Theme: tui.Dark},
159- renderOutsideLock: true,145+ renderOutsideLock: true,
160- streamFlushPending: true,
161 }146 }
162 147
163- revision := agent.Revision()148+ i.mu.Lock()
149+ i.startStreamLocked()
150+ i.stream.Push(finalText)
151+ i.stream.Finish()
152+ i.mu.Unlock()
153+ // The agent appends the finished reply while the pacer is still busy.
154+ agent.SetMessages(append(agent.Messages(), provider.Message{
155+ Role: provider.RoleAssistant,
156+ Content: []provider.Content{provider.TextBlock{Text: finalText}},
157+ }))
158+
164 i.mu.Lock()159 i.mu.Lock()
165 before := strings.Join(i.cachedChatLocked(80), "\n")160 before := strings.Join(i.cachedChatLocked(80), "\n")
166- i.streamFlushPending = false161+ for i.stream.Active() {
162+ i.stream.Tick()
163+ }
167 after := strings.Join(i.cachedChatLocked(80), "\n")164 after := strings.Join(i.cachedChatLocked(80), "\n")
168 i.mu.Unlock()165 i.mu.Unlock()
169 166
170- if got := agent.Revision(); got != revision {
171- t.Fatalf("stream flush changed the transcript revision: got %d, want %d", got, revision)
172- }
173 if strings.Contains(before, finalText) {167 if strings.Contains(before, finalText) {
174 t.Fatal("stable cache revealed the final message while stream flush was pending")168 t.Fatal("stable cache revealed the final message while stream flush was pending")
175 }169 }
@@ -179,11 +173,7 @@ func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
179 }173 }
180 174
181 func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {175 func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
182- i := &Interactive{176+ i := &Interactive{dirty: make(chan struct{}, 1), toolCalls: make(map[string]*tui.ToolCallView)}
183- dirty: make(chan struct{}, 1),
184- toolCalls: make(map[string]*tui.ToolCallView),
185- toolGate: make(map[string]int),
186- }
187 i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})177 i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
188 select {178 select {
189 case <-i.dirty:179 case <-i.dirty:
M packages/agent/modes/interactive_rendering.go +17 -40
@@ -34,7 +34,7 @@ func (i *Interactive) cachedChatLocked(cols int) []string {
3434 // A busy frame contains mutable streaming/tool state. Keep the stable
3535 // transcript cache, but never return a full-frame cache entry that would
3636 // hide a newly arrived delta or completed result.
37- if i.busy || i.streamOn || i.streamFlushPending {
37+ if i.busy || i.stream.Active() {
3838 return i.buildChatLocked(cols)
3939 }
4040 key := i.chatCacheKeyLocked(cols)
@@ -53,7 +53,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
5353 if i.agent != nil {
5454 rev = i.agent.Revision()
5555 }
56- showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
56+ showVer := len(i.view.Messages) == 0 && !i.stream.Active() && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
5757 return chatCacheKey{
5858 cols: cols,
5959 agentRev: rev,
@@ -240,8 +240,16 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
240240 }
241241 func (i *Interactive) buildChatLocked(cols int) []string {
242242 if i.agent != nil {
243- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
244- i.view.MessagesRevision = i.agent.Revision()
243+ msgs, rev := i.agent.Messages(), i.agent.Revision()
244+ // While live text is on screen, show the transcript only as it was
245+ // when the stream started. The agent appends the finished reply (and
246+ // fast tool results) before the pacer has painted it; revealing them
247+ // early would show the same text twice.
248+ if limit, anchorRev, ok := i.stream.transcriptLimit(len(msgs)); ok {
249+ msgs, rev = msgs[:limit], anchorRev
250+ }
251+ i.view.Messages = filterHiddenTranscriptMessages(msgs)
252+ i.view.MessagesRevision = rev
245253 } else {
246254 i.view.Messages = nil
247255 i.view.MessagesRevision = 0
@@ -255,39 +263,8 @@ func (i *Interactive) buildChatLocked(cols int) []string {
255263 // was anchored after. Clamp those anchors once so later turns still append
256264 // below the block instead of making it jump through the conversation.
257265 clampSessionInfoBlocks(i.sessionInfoBlocks, len(i.view.Messages))
258- // Pacer flush: while the streaming pacer is still draining the
259- // buffer (i.e. EvAssistantMessage already fired but more runes
260- // are queued), the final assistant message is already in
261- // i.agent.Messages() in full. Painting it in the transcript
262- // AND the streaming block at the same time shows the user the
263- // complete text immediately — which defeats the whole pacer.
264- // Hide the last message until the pacer catches up; once the
265- // flush-pending latch clears, the message is revealed (the
266- // streaming block disappears the same frame because streamOn
267- // flips off, so the transition is seamless).
268- if i.streamFlushPending && len(i.view.Messages) > 0 {
269- i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
270- }
271- i.view.Streaming = i.streaming.String()
272- i.view.StreamingActive = i.streamOn
273- // Guard against the narrow race where EvAssistantMessage has
274- // just promoted a streaming reply into the transcript but a
275- // render tick hasn't flipped streamOn off yet. Without the
276- // guard, the same text would appear twice (once as the
277- // in-flight streaming block, once as the last transcript
278- // message). We detect the duplicate strictly: the last
279- // assistant message's visible text must equal the streaming
280- // buffer. Just matching on role is too broad — it also hides
281- // the next round's typewriter streaming after a tool turn,
282- // because the last transcript message is always assistant
283- // (the tool-use block) until the follow-up summary lands.
284- if i.streamOn && i.streaming.Len() > 0 {
285- if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
286- if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
287- i.view.StreamingActive = false
288- }
289- }
290- }
266+ i.view.Streaming = i.stream.Text()
267+ i.view.StreamingActive = i.stream.Active()
291268 // Live tool-call view: only shown while a turn is in flight. Once
292269 // the agent is idle, every tool call has already been folded into
293270 // the transcript (as assistant.ToolCallBlock + a tool-role message),
@@ -302,7 +279,7 @@ func (i *Interactive) buildChatLocked(cols int) []string {
302279 // typing out. toolOrder is append-only in arrival order,
303280 // so once one tool is still gated, every later tool is too,
304281 // so stop here to avoid showing a tool out of sequence.
305- if !i.toolGateOpenLocked(id) {
282+ if !i.stream.GateOpen(id) {
306283 break
307284 }
308285 if tc, ok := i.toolCalls[id]; ok {
@@ -325,7 +302,7 @@ func (i *Interactive) buildChatLocked(cols int) []string {
325302 // no transcript yet. Disappears after the first message is sent.
326303 // The version suffix is shown for welcomeVersionDuration after
327304 // startup, then drops off automatically.
328- if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
305+ if len(i.view.Messages) == 0 && !i.stream.Active() && len(i.toolOrder) == 0 {
329306 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
330307 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
331308 }
@@ -1054,7 +1031,7 @@ func (i *Interactive) redraw() {
10541031 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
10551032 }
10561033 i.mu.Lock()
1057- if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
1034+ if i.pendingAlert != nil && !i.busy && !i.stream.Active() {
10581035 alert := *i.pendingAlert
10591036 i.pendingAlert = nil
10601037 i.emitAlertLocked(alert)
@@ -34,7 +34,7 @@ func (i *Interactive) cachedChatLocked(cols int) []string {
34 // A busy frame contains mutable streaming/tool state. Keep the stable34 // A busy frame contains mutable streaming/tool state. Keep the stable
35 // transcript cache, but never return a full-frame cache entry that would35 // transcript cache, but never return a full-frame cache entry that would
36 // hide a newly arrived delta or completed result.36 // hide a newly arrived delta or completed result.
37- if i.busy || i.streamOn || i.streamFlushPending {37+ if i.busy || i.stream.Active() {
38 return i.buildChatLocked(cols)38 return i.buildChatLocked(cols)
39 }39 }
40 key := i.chatCacheKeyLocked(cols)40 key := i.chatCacheKeyLocked(cols)
@@ -53,7 +53,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
53 if i.agent != nil {53 if i.agent != nil {
54 rev = i.agent.Revision()54 rev = i.agent.Revision()
55 }55 }
56- showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration56+ showVer := len(i.view.Messages) == 0 && !i.stream.Active() && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
57 return chatCacheKey{57 return chatCacheKey{
58 cols: cols,58 cols: cols,
59 agentRev: rev,59 agentRev: rev,
@@ -240,8 +240,16 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
240 }240 }
241 func (i *Interactive) buildChatLocked(cols int) []string {241 func (i *Interactive) buildChatLocked(cols int) []string {
242 if i.agent != nil {242 if i.agent != nil {
243- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())243+ msgs, rev := i.agent.Messages(), i.agent.Revision()
244- i.view.MessagesRevision = i.agent.Revision()244+ // While live text is on screen, show the transcript only as it was
245+ // when the stream started. The agent appends the finished reply (and
246+ // fast tool results) before the pacer has painted it; revealing them
247+ // early would show the same text twice.
248+ if limit, anchorRev, ok := i.stream.transcriptLimit(len(msgs)); ok {
249+ msgs, rev = msgs[:limit], anchorRev
250+ }
251+ i.view.Messages = filterHiddenTranscriptMessages(msgs)
252+ i.view.MessagesRevision = rev
245 } else {253 } else {
246 i.view.Messages = nil254 i.view.Messages = nil
247 i.view.MessagesRevision = 0255 i.view.MessagesRevision = 0
@@ -255,39 +263,8 @@ func (i *Interactive) buildChatLocked(cols int) []string {
255 // was anchored after. Clamp those anchors once so later turns still append263 // was anchored after. Clamp those anchors once so later turns still append
256 // below the block instead of making it jump through the conversation.264 // below the block instead of making it jump through the conversation.
257 clampSessionInfoBlocks(i.sessionInfoBlocks, len(i.view.Messages))265 clampSessionInfoBlocks(i.sessionInfoBlocks, len(i.view.Messages))
258- // Pacer flush: while the streaming pacer is still draining the266+ i.view.Streaming = i.stream.Text()
259- // buffer (i.e. EvAssistantMessage already fired but more runes267+ i.view.StreamingActive = i.stream.Active()
260- // are queued), the final assistant message is already in
261- // i.agent.Messages() in full. Painting it in the transcript
262- // AND the streaming block at the same time shows the user the
263- // complete text immediately — which defeats the whole pacer.
264- // Hide the last message until the pacer catches up; once the
265- // flush-pending latch clears, the message is revealed (the
266- // streaming block disappears the same frame because streamOn
267- // flips off, so the transition is seamless).
268- if i.streamFlushPending && len(i.view.Messages) > 0 {
269- i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
270- }
271- i.view.Streaming = i.streaming.String()
272- i.view.StreamingActive = i.streamOn
273- // Guard against the narrow race where EvAssistantMessage has
274- // just promoted a streaming reply into the transcript but a
275- // render tick hasn't flipped streamOn off yet. Without the
276- // guard, the same text would appear twice (once as the
277- // in-flight streaming block, once as the last transcript
278- // message). We detect the duplicate strictly: the last
279- // assistant message's visible text must equal the streaming
280- // buffer. Just matching on role is too broad — it also hides
281- // the next round's typewriter streaming after a tool turn,
282- // because the last transcript message is always assistant
283- // (the tool-use block) until the follow-up summary lands.
284- if i.streamOn && i.streaming.Len() > 0 {
285- if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
286- if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
287- i.view.StreamingActive = false
288- }
289- }
290- }
291 // Live tool-call view: only shown while a turn is in flight. Once268 // Live tool-call view: only shown while a turn is in flight. Once
292 // the agent is idle, every tool call has already been folded into269 // the agent is idle, every tool call has already been folded into
293 // the transcript (as assistant.ToolCallBlock + a tool-role message),270 // the transcript (as assistant.ToolCallBlock + a tool-role message),
@@ -302,7 +279,7 @@ func (i *Interactive) buildChatLocked(cols int) []string {
302 // typing out. toolOrder is append-only in arrival order,279 // typing out. toolOrder is append-only in arrival order,
303 // so once one tool is still gated, every later tool is too,280 // so once one tool is still gated, every later tool is too,
304 // so stop here to avoid showing a tool out of sequence.281 // so stop here to avoid showing a tool out of sequence.
305- if !i.toolGateOpenLocked(id) {282+ if !i.stream.GateOpen(id) {
306 break283 break
307 }284 }
308 if tc, ok := i.toolCalls[id]; ok {285 if tc, ok := i.toolCalls[id]; ok {
@@ -325,7 +302,7 @@ func (i *Interactive) buildChatLocked(cols int) []string {
325 // no transcript yet. Disappears after the first message is sent.302 // no transcript yet. Disappears after the first message is sent.
326 // The version suffix is shown for welcomeVersionDuration after303 // The version suffix is shown for welcomeVersionDuration after
327 // startup, then drops off automatically.304 // startup, then drops off automatically.
328- if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {305+ if len(i.view.Messages) == 0 && !i.stream.Active() && len(i.toolOrder) == 0 {
329 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration306 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
330 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)307 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
331 }308 }
@@ -1054,7 +1031,7 @@ func (i *Interactive) redraw() {
1054 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)1031 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1055 }1032 }
1056 i.mu.Lock()1033 i.mu.Lock()
1057- if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {1034+ if i.pendingAlert != nil && !i.busy && !i.stream.Active() {
1058 alert := *i.pendingAlert1035 alert := *i.pendingAlert
1059 i.pendingAlert = nil1036 i.pendingAlert = nil
1060 i.emitAlertLocked(alert)1037 i.emitAlertLocked(alert)
M packages/agent/modes/interactive_resident_subagents.go +1 -81
@@ -6,7 +6,6 @@ import (
66 "os"
77 "path/filepath"
88 "strings"
9- "time"
109
1110 "github.com/bnema/zut/packages/agent/internal/orchestration"
1211 "github.com/bnema/zut/packages/agent/modes/telegram"
@@ -995,7 +994,7 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
995994 }
996995 i.toolCalls = map[string]*tui.ToolCallView{}
997996 i.toolOrder = nil
998- i.toolGate = map[string]int{}
997+ i.stream.Reset()
999998 i.extNotes = nil
1000999 if selection.restoreDraft {
10011000 i.ed.SetValue(selection.draftText)
@@ -1073,18 +1072,6 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
10731072 func formatInt(n int) string {
10741073 return fmt.Sprintf("%d", n)
10751074 }
1076-func assistantText(m provider.Message) string {
1077- var sb strings.Builder
1078- for _, c := range m.Content {
1079- if tb, ok := c.(provider.TextBlock); ok {
1080- if sb.Len() > 0 {
1081- sb.WriteByte('\n')
1082- }
1083- sb.WriteString(tb.Text)
1084- }
1085- }
1086- return sb.String()
1087-}
10881075 func (i *Interactive) resetTranscriptRenderLocked() {
10891076 i.view.InvalidateRenderCache()
10901077 i.chatCacheValid = false
@@ -1094,35 +1081,6 @@ func (i *Interactive) resetTranscriptRenderLocked() {
10941081 i.prevScrollOffset = 0
10951082 i.requestRendererInvalidate()
10961083 }
1097-func (i *Interactive) resetStreamingStateLocked() {
1098- i.streaming.Reset()
1099- i.streamPending = i.streamPending[:0]
1100- i.streamFlushPending = false
1101- i.streamOn = false
1102- i.openAllToolGatesLocked()
1103-}
1104-func (i *Interactive) openAllToolGatesLocked() {
1105- for id := range i.toolGate {
1106- i.toolGate[id] = 0
1107- }
1108-}
1109-func (i *Interactive) gateToolLocked(id string) {
1110- if _, ok := i.toolGate[id]; ok {
1111- return
1112- }
1113- if !i.streamOn {
1114- i.toolGate[id] = 0
1115- return
1116- }
1117- i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
1118-}
1119-func (i *Interactive) toolGateOpenLocked(id string) bool {
1120- gate, ok := i.toolGate[id]
1121- if !ok || gate == 0 {
1122- return true
1123- }
1124- return i.streaming.Len() >= gate
1125-}
11261084 func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
11271085 if i.cfg.OnAssistant != nil {
11281086 i.cfg.OnAssistant(m)
@@ -1142,41 +1100,3 @@ func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
11421100 }
11431101 }
11441102 }
1145-func (i *Interactive) runStreamPacer(ctx context.Context) {
1146- t := time.NewTicker(paintPaceInterval)
1147- defer t.Stop()
1148- for {
1149- select {
1150- case <-ctx.Done():
1151- return
1152- case <-t.C:
1153- i.mu.Lock()
1154- if len(i.streamPending) == 0 {
1155- // EvAssistantMessage already fired but the pacer
1156- // was still draining a tick ago. Everything is now
1157- // painted; clear the streaming flags so the next
1158- // redraw shows the finalised transcript message
1159- // and hides the streaming overlay.
1160- if i.streamFlushPending {
1161- i.streamFlushPending = false
1162- i.streaming.Reset()
1163- i.streamOn = false
1164- i.openAllToolGatesLocked()
1165- i.mu.Unlock()
1166- i.invalidate()
1167- continue
1168- }
1169- i.mu.Unlock()
1170- continue
1171- }
1172- n := paintPaceRate
1173- if n > len(i.streamPending) {
1174- n = len(i.streamPending)
1175- }
1176- i.streaming.WriteString(string(i.streamPending[:n]))
1177- i.streamPending = i.streamPending[n:]
1178- i.mu.Unlock()
1179- i.invalidate()
1180- }
1181- }
1182-}
@@ -6,7 +6,6 @@ import (
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8 "strings"8 "strings"
9- "time"
10 9
11 "github.com/bnema/zut/packages/agent/internal/orchestration"10 "github.com/bnema/zut/packages/agent/internal/orchestration"
12 "github.com/bnema/zut/packages/agent/modes/telegram"11 "github.com/bnema/zut/packages/agent/modes/telegram"
@@ -995,7 +994,7 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
995 }994 }
996 i.toolCalls = map[string]*tui.ToolCallView{}995 i.toolCalls = map[string]*tui.ToolCallView{}
997 i.toolOrder = nil996 i.toolOrder = nil
998- i.toolGate = map[string]int{}997+ i.stream.Reset()
999 i.extNotes = nil998 i.extNotes = nil
1000 if selection.restoreDraft {999 if selection.restoreDraft {
1001 i.ed.SetValue(selection.draftText)1000 i.ed.SetValue(selection.draftText)
@@ -1073,18 +1072,6 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
1073 func formatInt(n int) string {1072 func formatInt(n int) string {
1074 return fmt.Sprintf("%d", n)1073 return fmt.Sprintf("%d", n)
1075 }1074 }
1076-func assistantText(m provider.Message) string {
1077- var sb strings.Builder
1078- for _, c := range m.Content {
1079- if tb, ok := c.(provider.TextBlock); ok {
1080- if sb.Len() > 0 {
1081- sb.WriteByte('\n')
1082- }
1083- sb.WriteString(tb.Text)
1084- }
1085- }
1086- return sb.String()
1087-}
1088 func (i *Interactive) resetTranscriptRenderLocked() {1075 func (i *Interactive) resetTranscriptRenderLocked() {
1089 i.view.InvalidateRenderCache()1076 i.view.InvalidateRenderCache()
1090 i.chatCacheValid = false1077 i.chatCacheValid = false
@@ -1094,35 +1081,6 @@ func (i *Interactive) resetTranscriptRenderLocked() {
1094 i.prevScrollOffset = 01081 i.prevScrollOffset = 0
1095 i.requestRendererInvalidate()1082 i.requestRendererInvalidate()
1096 }1083 }
1097-func (i *Interactive) resetStreamingStateLocked() {
1098- i.streaming.Reset()
1099- i.streamPending = i.streamPending[:0]
1100- i.streamFlushPending = false
1101- i.streamOn = false
1102- i.openAllToolGatesLocked()
1103-}
1104-func (i *Interactive) openAllToolGatesLocked() {
1105- for id := range i.toolGate {
1106- i.toolGate[id] = 0
1107- }
1108-}
1109-func (i *Interactive) gateToolLocked(id string) {
1110- if _, ok := i.toolGate[id]; ok {
1111- return
1112- }
1113- if !i.streamOn {
1114- i.toolGate[id] = 0
1115- return
1116- }
1117- i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
1118-}
1119-func (i *Interactive) toolGateOpenLocked(id string) bool {
1120- gate, ok := i.toolGate[id]
1121- if !ok || gate == 0 {
1122- return true
1123- }
1124- return i.streaming.Len() >= gate
1125-}
1126 func (i *Interactive) assistantMessageSideEffects(m provider.Message) {1084 func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
1127 if i.cfg.OnAssistant != nil {1085 if i.cfg.OnAssistant != nil {
1128 i.cfg.OnAssistant(m)1086 i.cfg.OnAssistant(m)
@@ -1142,41 +1100,3 @@ func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
1142 }1100 }
1143 }1101 }
1144 }1102 }
1145-func (i *Interactive) runStreamPacer(ctx context.Context) {
1146- t := time.NewTicker(paintPaceInterval)
1147- defer t.Stop()
1148- for {
1149- select {
1150- case <-ctx.Done():
1151- return
1152- case <-t.C:
1153- i.mu.Lock()
1154- if len(i.streamPending) == 0 {
1155- // EvAssistantMessage already fired but the pacer
1156- // was still draining a tick ago. Everything is now
1157- // painted; clear the streaming flags so the next
1158- // redraw shows the finalised transcript message
1159- // and hides the streaming overlay.
1160- if i.streamFlushPending {
1161- i.streamFlushPending = false
1162- i.streaming.Reset()
1163- i.streamOn = false
1164- i.openAllToolGatesLocked()
1165- i.mu.Unlock()
1166- i.invalidate()
1167- continue
1168- }
1169- i.mu.Unlock()
1170- continue
1171- }
1172- n := paintPaceRate
1173- if n > len(i.streamPending) {
1174- n = len(i.streamPending)
1175- }
1176- i.streaming.WriteString(string(i.streamPending[:n]))
1177- i.streamPending = i.streamPending[n:]
1178- i.mu.Unlock()
1179- i.invalidate()
1180- }
1181- }
1182-}
M packages/agent/modes/interactive_turn.go +9 -10
@@ -214,12 +214,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
214214 i.cancelTurn = cancel
215215 i.statusErr = ""
216216 i.statusOK = ""
217- i.streaming.Reset()
218- i.streamOn = true
219217 i.pendingAlert = nil
220218 i.toolCalls = map[string]*tui.ToolCallView{}
221219 i.toolOrder = nil
222- i.toolGate = map[string]int{}
220+ i.stream.Reset()
223221 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
224222 i.scrollOffset = 0 // jump back to the bottom on new turn
225223 // Lift the resume tail cap once the user starts interacting. The
@@ -277,12 +275,13 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
277275 // provider's final event; publishing idle before inspecting queues can
278276 // strand that summary in the core agent queue or start an overlapping
279277 // turn.
280- // Don't touch streamPending / streamFlushPending here — the
281- // pacer may still be draining the final deltas and needs to
282- // paint them even though Prompt has returned. It will reset
283- // streamOn on its own once the buffer empties.
284- if len(i.streamPending) == 0 {
285- i.streamOn = false
278+ // On success, let the pacer finish painting the final deltas; it
279+ // returns the presenter to idle when done. A cancelled turn stops
280+ // typing at once.
281+ if ctx.Err() != nil {
282+ i.stream.Reset()
283+ } else {
284+ i.stream.Finish()
286285 }
287286 i.cancelTurn = nil
288287 pendingIdleWork := i.takePendingIdleWorkLocked()
@@ -463,7 +462,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
463462 // race the still-live stream frame; otherwise stale deltas can repaint
464463 // after the replacement and corrupt the scrollback renderer's model.
465464 if recoverContextOverflow || shouldAutoCompact {
466- i.resetStreamingStateLocked()
465+ i.stream.Reset()
467466 }
468467 // A clean ordinary turn that owns a pending Esc return resumes the
469468 // same still-active goal exactly once through the existing scheduler.
@@ -214,12 +214,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
214 i.cancelTurn = cancel214 i.cancelTurn = cancel
215 i.statusErr = ""215 i.statusErr = ""
216 i.statusOK = ""216 i.statusOK = ""
217- i.streaming.Reset()
218- i.streamOn = true
219 i.pendingAlert = nil217 i.pendingAlert = nil
220 i.toolCalls = map[string]*tui.ToolCallView{}218 i.toolCalls = map[string]*tui.ToolCallView{}
221 i.toolOrder = nil219 i.toolOrder = nil
222- i.toolGate = map[string]int{}220+ i.stream.Reset()
223 i.extNotes = nil // ext notes are one-shot; a new prompt clears them221 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
224 i.scrollOffset = 0 // jump back to the bottom on new turn222 i.scrollOffset = 0 // jump back to the bottom on new turn
225 // Lift the resume tail cap once the user starts interacting. The223 // Lift the resume tail cap once the user starts interacting. The
@@ -277,12 +275,13 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
277 // provider's final event; publishing idle before inspecting queues can275 // provider's final event; publishing idle before inspecting queues can
278 // strand that summary in the core agent queue or start an overlapping276 // strand that summary in the core agent queue or start an overlapping
279 // turn.277 // turn.
280- // Don't touch streamPending / streamFlushPending here — the278+ // On success, let the pacer finish painting the final deltas; it
281- // pacer may still be draining the final deltas and needs to279+ // returns the presenter to idle when done. A cancelled turn stops
282- // paint them even though Prompt has returned. It will reset280+ // typing at once.
283- // streamOn on its own once the buffer empties.281+ if ctx.Err() != nil {
284- if len(i.streamPending) == 0 {282+ i.stream.Reset()
285- i.streamOn = false283+ } else {
284+ i.stream.Finish()
286 }285 }
287 i.cancelTurn = nil286 i.cancelTurn = nil
288 pendingIdleWork := i.takePendingIdleWorkLocked()287 pendingIdleWork := i.takePendingIdleWorkLocked()
@@ -463,7 +462,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
463 // race the still-live stream frame; otherwise stale deltas can repaint462 // race the still-live stream frame; otherwise stale deltas can repaint
464 // after the replacement and corrupt the scrollback renderer's model.463 // after the replacement and corrupt the scrollback renderer's model.
465 if recoverContextOverflow || shouldAutoCompact {464 if recoverContextOverflow || shouldAutoCompact {
466- i.resetStreamingStateLocked()465+ i.stream.Reset()
467 }466 }
468 // A clean ordinary turn that owns a pending Esc return resumes the467 // A clean ordinary turn that owns a pending Esc return resumes the
469 // same still-active goal exactly once through the existing scheduler.468 // same still-active goal exactly once through the existing scheduler.
A packages/agent/modes/stream_presenter.go +230 -0
@@ -0,0 +1,230 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "time"
7+)
8+
9+// paintPaceRate is the minimum number of runes the streaming pacer releases
10+// per tick. With a 16ms tick this is ~375 runes/s, which reads like typing
11+// when a provider sends a few fat chunks instead of a steady drip.
12+const paintPaceRate = 6
13+
14+// paintCatchUpTicks sets how fast a backlog drains: each tick releases about
15+// 1/paintCatchUpTicks of the unpainted text (at least paintPaceRate runes),
16+// so a large lag shrinks geometrically instead of growing with a fast stream.
17+const paintCatchUpTicks = 24
18+
19+// paintPaceInterval is the pacer tick. It matches the redraw throttle so the
20+// pacer never produces frames faster than the terminal can show them.
21+const paintPaceInterval = 16 * time.Millisecond
22+
23+type streamState uint8
24+
25+const (
26+ // streamIdle: no assistant reply is on screen as live text.
27+ streamIdle streamState = iota
28+ // streamReceiving: the provider is still sending text deltas.
29+ streamReceiving
30+ // streamDraining: the provider finished; the pacer is painting the
31+ // remaining buffered text before the transcript takes over.
32+ streamDraining
33+)
34+
35+// streamPresenter owns the paced, live display of one assistant reply.
36+//
37+// The agent appends the finished message to the transcript on its own
38+// goroutine, while the pacer is often still typing that same text. Showing
39+// both would paint the reply twice. The presenter prevents that with one
40+// rule: while it is not idle, the transcript is shown only up to the length
41+// it had when the stream started (the anchor). Everything the agent appended
42+// since then — the assistant message and any fast tool results — becomes
43+// visible in the same frame that the live text disappears.
44+//
45+// All methods must be called with Interactive.mu held.
46+type streamPresenter struct {
47+ state streamState
48+ painted strings.Builder
49+ paintedRunes int
50+ pending []rune
51+
52+ // anchor is the raw transcript length when the stream started, and
53+ // anchorRev the transcript revision at that length. A negative anchor
54+ // disables transcript clipping (no agent attached).
55+ anchor int
56+ anchorRev uint64
57+
58+ // gates holds, per tool-call id, how many runes of live text must be
59+ // painted before that tool block may appear, so a tool never shows up
60+ // above the prose that preceded it. A missing entry means visible.
61+ gates map[string]int
62+}
63+
64+// Active reports whether live text is still owned by the presenter.
65+func (s *streamPresenter) Active() bool { return s.state != streamIdle }
66+
67+// Text returns the live text painted so far.
68+func (s *streamPresenter) Text() string { return s.painted.String() }
69+
70+// Start begins a new reply. Anything still buffered from a previous reply is
71+// dropped: that reply is already complete in the transcript.
72+func (s *streamPresenter) Start(anchor int, anchorRev uint64) {
73+ s.Reset()
74+ s.state = streamReceiving
75+ s.anchor = anchor
76+ s.anchorRev = anchorRev
77+}
78+
79+// Push buffers a provider text delta for pacing.
80+func (s *streamPresenter) Push(delta string) {
81+ if s.state != streamReceiving || delta == "" {
82+ return
83+ }
84+ s.pending = append(s.pending, []rune(delta)...)
85+}
86+
87+// Finish marks the provider side of the reply as complete. The presenter
88+// stays active until the pacer has painted every buffered rune.
89+func (s *streamPresenter) Finish() {
90+ if s.state == streamIdle {
91+ return
92+ }
93+ if len(s.pending) == 0 {
94+ s.Reset()
95+ return
96+ }
97+ s.state = streamDraining
98+}
99+
100+// Reset returns to idle immediately and reveals the whole transcript.
101+func (s *streamPresenter) Reset() {
102+ s.state = streamIdle
103+ s.painted.Reset()
104+ s.paintedRunes = 0
105+ s.pending = s.pending[:0]
106+ s.anchor = 0
107+ s.anchorRev = 0
108+ s.gates = nil
109+}
110+
111+// needsTick reports whether the pacer has work to do.
112+func (s *streamPresenter) needsTick() bool {
113+ return len(s.pending) > 0 || s.state == streamDraining
114+}
115+
116+// Tick paints the next batch of buffered runes. It reports whether the
117+// visible state changed.
118+func (s *streamPresenter) Tick() bool {
119+ if len(s.pending) == 0 {
120+ if s.state == streamDraining {
121+ s.Reset()
122+ return true
123+ }
124+ return false
125+ }
126+ n := max(paintPaceRate, (len(s.pending)+paintCatchUpTicks-1)/paintCatchUpTicks)
127+ n = min(n, len(s.pending))
128+ s.painted.WriteString(string(s.pending[:n]))
129+ s.paintedRunes += n
130+ s.pending = s.pending[n:]
131+ if len(s.pending) == 0 && s.state == streamDraining {
132+ // The last rune is painted: hand over to the transcript in the same
133+ // frame instead of leaving a one-tick gap.
134+ s.Reset()
135+ }
136+ return true
137+}
138+
139+// Gate records where a tool block may appear. The first registration wins.
140+func (s *streamPresenter) Gate(id string) {
141+ if s.state == streamIdle {
142+ return
143+ }
144+ if _, ok := s.gates[id]; ok {
145+ return
146+ }
147+ if s.gates == nil {
148+ s.gates = make(map[string]int)
149+ }
150+ s.gates[id] = s.paintedRunes + len(s.pending)
151+}
152+
153+// GateOpen reports whether the tool block may render yet.
154+func (s *streamPresenter) GateOpen(id string) bool {
155+ gate, ok := s.gates[id]
156+ return !ok || s.paintedRunes >= gate
157+}
158+
159+// transcriptLimit returns how many raw transcript messages may be shown and
160+// the revision describing that prefix. ok is false when no clipping applies.
161+func (s *streamPresenter) transcriptLimit(total int) (limit int, rev uint64, ok bool) {
162+ if s.state == streamIdle || s.anchor < 0 || s.anchor > total {
163+ // A shorter transcript means it was replaced; never hide content of
164+ // a transcript the anchor does not describe.
165+ return 0, 0, false
166+ }
167+ return s.anchor, s.anchorRev, true
168+}
169+
170+// startStreamLocked anchors a new reply at the current transcript length.
171+func (i *Interactive) startStreamLocked() {
172+ anchor, rev := -1, uint64(0)
173+ if i.agent != nil {
174+ // The agent goroutine delivers this event synchronously, so the
175+ // transcript cannot grow between these two reads.
176+ anchor, rev = i.agent.MessageCount(), i.agent.Revision()
177+ }
178+ i.stream.Start(anchor, rev)
179+}
180+
181+// wakeStreamPacer nudges an idle pacer without blocking.
182+func (i *Interactive) wakeStreamPacer() {
183+ select {
184+ case i.streamWake <- struct{}{}:
185+ default:
186+ }
187+}
188+
189+// runStreamPacer paints buffered deltas a small batch per tick. It sleeps
190+// while there is nothing to paint and exits when ctx is cancelled.
191+//
192+// Providers chunk text very differently: the Anthropic API-key path drips
193+// many small deltas, while the OAuth path can coalesce a reply into a few
194+// large chunks that would otherwise just appear. Pacing makes every path
195+// look the same on screen.
196+func (i *Interactive) runStreamPacer(ctx context.Context) {
197+ ticker := time.NewTicker(paintPaceInterval)
198+ ticker.Stop()
199+ defer ticker.Stop()
200+ running := false
201+ for {
202+ i.mu.Lock()
203+ need := i.stream.needsTick()
204+ i.mu.Unlock()
205+ switch {
206+ case need && !running:
207+ ticker.Reset(paintPaceInterval)
208+ running = true
209+ case !need && running:
210+ ticker.Stop()
211+ running = false
212+ }
213+ var tick <-chan time.Time
214+ if running {
215+ tick = ticker.C
216+ }
217+ select {
218+ case <-ctx.Done():
219+ return
220+ case <-i.streamWake:
221+ case <-tick:
222+ i.mu.Lock()
223+ changed := i.stream.Tick()
224+ i.mu.Unlock()
225+ if changed {
226+ i.invalidate()
227+ }
228+ }
229+ }
230+}
@@ -0,0 +1,230 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "time"
7+)
8+
9+// paintPaceRate is the minimum number of runes the streaming pacer releases
10+// per tick. With a 16ms tick this is ~375 runes/s, which reads like typing
11+// when a provider sends a few fat chunks instead of a steady drip.
12+const paintPaceRate = 6
13+
14+// paintCatchUpTicks sets how fast a backlog drains: each tick releases about
15+// 1/paintCatchUpTicks of the unpainted text (at least paintPaceRate runes),
16+// so a large lag shrinks geometrically instead of growing with a fast stream.
17+const paintCatchUpTicks = 24
18+
19+// paintPaceInterval is the pacer tick. It matches the redraw throttle so the
20+// pacer never produces frames faster than the terminal can show them.
21+const paintPaceInterval = 16 * time.Millisecond
22+
23+type streamState uint8
24+
25+const (
26+ // streamIdle: no assistant reply is on screen as live text.
27+ streamIdle streamState = iota
28+ // streamReceiving: the provider is still sending text deltas.
29+ streamReceiving
30+ // streamDraining: the provider finished; the pacer is painting the
31+ // remaining buffered text before the transcript takes over.
32+ streamDraining
33+)
34+
35+// streamPresenter owns the paced, live display of one assistant reply.
36+//
37+// The agent appends the finished message to the transcript on its own
38+// goroutine, while the pacer is often still typing that same text. Showing
39+// both would paint the reply twice. The presenter prevents that with one
40+// rule: while it is not idle, the transcript is shown only up to the length
41+// it had when the stream started (the anchor). Everything the agent appended
42+// since then — the assistant message and any fast tool results — becomes
43+// visible in the same frame that the live text disappears.
44+//
45+// All methods must be called with Interactive.mu held.
46+type streamPresenter struct {
47+ state streamState
48+ painted strings.Builder
49+ paintedRunes int
50+ pending []rune
51+
52+ // anchor is the raw transcript length when the stream started, and
53+ // anchorRev the transcript revision at that length. A negative anchor
54+ // disables transcript clipping (no agent attached).
55+ anchor int
56+ anchorRev uint64
57+
58+ // gates holds, per tool-call id, how many runes of live text must be
59+ // painted before that tool block may appear, so a tool never shows up
60+ // above the prose that preceded it. A missing entry means visible.
61+ gates map[string]int
62+}
63+
64+// Active reports whether live text is still owned by the presenter.
65+func (s *streamPresenter) Active() bool { return s.state != streamIdle }
66+
67+// Text returns the live text painted so far.
68+func (s *streamPresenter) Text() string { return s.painted.String() }
69+
70+// Start begins a new reply. Anything still buffered from a previous reply is
71+// dropped: that reply is already complete in the transcript.
72+func (s *streamPresenter) Start(anchor int, anchorRev uint64) {
73+ s.Reset()
74+ s.state = streamReceiving
75+ s.anchor = anchor
76+ s.anchorRev = anchorRev
77+}
78+
79+// Push buffers a provider text delta for pacing.
80+func (s *streamPresenter) Push(delta string) {
81+ if s.state != streamReceiving || delta == "" {
82+ return
83+ }
84+ s.pending = append(s.pending, []rune(delta)...)
85+}
86+
87+// Finish marks the provider side of the reply as complete. The presenter
88+// stays active until the pacer has painted every buffered rune.
89+func (s *streamPresenter) Finish() {
90+ if s.state == streamIdle {
91+ return
92+ }
93+ if len(s.pending) == 0 {
94+ s.Reset()
95+ return
96+ }
97+ s.state = streamDraining
98+}
99+
100+// Reset returns to idle immediately and reveals the whole transcript.
101+func (s *streamPresenter) Reset() {
102+ s.state = streamIdle
103+ s.painted.Reset()
104+ s.paintedRunes = 0
105+ s.pending = s.pending[:0]
106+ s.anchor = 0
107+ s.anchorRev = 0
108+ s.gates = nil
109+}
110+
111+// needsTick reports whether the pacer has work to do.
112+func (s *streamPresenter) needsTick() bool {
113+ return len(s.pending) > 0 || s.state == streamDraining
114+}
115+
116+// Tick paints the next batch of buffered runes. It reports whether the
117+// visible state changed.
118+func (s *streamPresenter) Tick() bool {
119+ if len(s.pending) == 0 {
120+ if s.state == streamDraining {
121+ s.Reset()
122+ return true
123+ }
124+ return false
125+ }
126+ n := max(paintPaceRate, (len(s.pending)+paintCatchUpTicks-1)/paintCatchUpTicks)
127+ n = min(n, len(s.pending))
128+ s.painted.WriteString(string(s.pending[:n]))
129+ s.paintedRunes += n
130+ s.pending = s.pending[n:]
131+ if len(s.pending) == 0 && s.state == streamDraining {
132+ // The last rune is painted: hand over to the transcript in the same
133+ // frame instead of leaving a one-tick gap.
134+ s.Reset()
135+ }
136+ return true
137+}
138+
139+// Gate records where a tool block may appear. The first registration wins.
140+func (s *streamPresenter) Gate(id string) {
141+ if s.state == streamIdle {
142+ return
143+ }
144+ if _, ok := s.gates[id]; ok {
145+ return
146+ }
147+ if s.gates == nil {
148+ s.gates = make(map[string]int)
149+ }
150+ s.gates[id] = s.paintedRunes + len(s.pending)
151+}
152+
153+// GateOpen reports whether the tool block may render yet.
154+func (s *streamPresenter) GateOpen(id string) bool {
155+ gate, ok := s.gates[id]
156+ return !ok || s.paintedRunes >= gate
157+}
158+
159+// transcriptLimit returns how many raw transcript messages may be shown and
160+// the revision describing that prefix. ok is false when no clipping applies.
161+func (s *streamPresenter) transcriptLimit(total int) (limit int, rev uint64, ok bool) {
162+ if s.state == streamIdle || s.anchor < 0 || s.anchor > total {
163+ // A shorter transcript means it was replaced; never hide content of
164+ // a transcript the anchor does not describe.
165+ return 0, 0, false
166+ }
167+ return s.anchor, s.anchorRev, true
168+}
169+
170+// startStreamLocked anchors a new reply at the current transcript length.
171+func (i *Interactive) startStreamLocked() {
172+ anchor, rev := -1, uint64(0)
173+ if i.agent != nil {
174+ // The agent goroutine delivers this event synchronously, so the
175+ // transcript cannot grow between these two reads.
176+ anchor, rev = i.agent.MessageCount(), i.agent.Revision()
177+ }
178+ i.stream.Start(anchor, rev)
179+}
180+
181+// wakeStreamPacer nudges an idle pacer without blocking.
182+func (i *Interactive) wakeStreamPacer() {
183+ select {
184+ case i.streamWake <- struct{}{}:
185+ default:
186+ }
187+}
188+
189+// runStreamPacer paints buffered deltas a small batch per tick. It sleeps
190+// while there is nothing to paint and exits when ctx is cancelled.
191+//
192+// Providers chunk text very differently: the Anthropic API-key path drips
193+// many small deltas, while the OAuth path can coalesce a reply into a few
194+// large chunks that would otherwise just appear. Pacing makes every path
195+// look the same on screen.
196+func (i *Interactive) runStreamPacer(ctx context.Context) {
197+ ticker := time.NewTicker(paintPaceInterval)
198+ ticker.Stop()
199+ defer ticker.Stop()
200+ running := false
201+ for {
202+ i.mu.Lock()
203+ need := i.stream.needsTick()
204+ i.mu.Unlock()
205+ switch {
206+ case need && !running:
207+ ticker.Reset(paintPaceInterval)
208+ running = true
209+ case !need && running:
210+ ticker.Stop()
211+ running = false
212+ }
213+ var tick <-chan time.Time
214+ if running {
215+ tick = ticker.C
216+ }
217+ select {
218+ case <-ctx.Done():
219+ return
220+ case <-i.streamWake:
221+ case <-tick:
222+ i.mu.Lock()
223+ changed := i.stream.Tick()
224+ i.mu.Unlock()
225+ if changed {
226+ i.invalidate()
227+ }
228+ }
229+ }
230+}
A packages/agent/modes/stream_presenter_test.go +224 -0
@@ -0,0 +1,224 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+ "github.com/bnema/zut/packages/tui"
12+)
13+
14+// scriptedTurnsClient replays one scripted event list per provider request.
15+type scriptedTurnsClient struct {
16+ turns [][]provider.Event
17+ call int
18+}
19+
20+func (c *scriptedTurnsClient) Name() string { return "scripted" }
21+
22+func (c *scriptedTurnsClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
23+ events := c.turns[min(c.call, len(c.turns)-1)]
24+ c.call++
25+ out := make(chan provider.Event, len(events))
26+ for _, ev := range events {
27+ out <- ev
28+ }
29+ close(out)
30+ return out, nil
31+}
32+
33+type echoTool struct{}
34+
35+func (echoTool) Name() string { return "echo" }
36+func (echoTool) Description() string { return "echo" }
37+func (echoTool) Schema() json.RawMessage {
38+ return json.RawMessage(`{"type":"object"}`)
39+}
40+func (echoTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
41+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "tool output"}}}, nil
42+}
43+
44+func textTurn(text string, stop provider.StopReason, extra ...provider.Content) []provider.Event {
45+ content := append([]provider.Content{provider.TextBlock{Text: text}}, extra...)
46+ var events []provider.Event
47+ // Fat chunks like the Anthropic OAuth path: the provider is far ahead of
48+ // the pacer when the message completes.
49+ for chunk := range strings.SplitSeq(text, " ") {
50+ events = append(events, provider.EventTextDelta{Delta: chunk + " "})
51+ }
52+ return append(events, provider.EventDone{Stop: stop, Message: provider.Message{Role: provider.RoleAssistant, Content: content}})
53+}
54+
55+// countVisible reports how many times text appears in the visible chat.
56+func countVisible(i *Interactive, text string) int {
57+ i.mu.Lock()
58+ defer i.mu.Unlock()
59+ return strings.Count(stripANSIBytes(strings.Join(i.buildChatLocked(120), "\n")), text)
60+}
61+
62+// newStreamRaceInteractive wires an agent to an interactive view without
63+// the pacer goroutine, so the test controls exactly when text is painted.
64+func newStreamRaceInteractive(t *testing.T, client provider.Client, tools core.Registry) (*Interactive, *core.Agent) {
65+ t.Helper()
66+ ag := core.NewAgent(client, "test-model", "", tools)
67+ i := &Interactive{
68+ agent: ag,
69+ view: &tui.View{Theme: tui.Dark},
70+ toolCalls: map[string]*tui.ToolCallView{},
71+ dirty: make(chan struct{}, 1),
72+ busy: true,
73+ }
74+ return i, ag
75+}
76+
77+// Race 1: the agent appends the finished reply and persists it before the UI
78+// receives EvAssistantMessage. A frame drawn in that window must not show the
79+// reply both as a transcript message and as partial live text.
80+func TestStreamReplyNotDuplicatedWhileMessageIsPersisted(t *testing.T) {
81+ const reply = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu"
82+ client := &scriptedTurnsClient{turns: [][]provider.Event{textTurn(reply, provider.StopEnd)}}
83+ i, ag := newStreamRaceInteractive(t, client, nil)
84+
85+ var seen []int
86+ ag.OnMessageAppended = func(m provider.Message) {
87+ if m.Role != provider.RoleAssistant {
88+ return
89+ }
90+ // Paint part of the reply, as the pacer would have by now.
91+ i.mu.Lock()
92+ drainTicks(&i.stream, 3)
93+ i.mu.Unlock()
94+ seen = append(seen, countVisible(i, "alpha"))
95+ }
96+ if err := ag.Prompt(context.Background(), "question", nil, i.handleEvent); err != nil {
97+ t.Fatal(err)
98+ }
99+ if len(seen) != 1 || seen[0] != 1 {
100+ t.Fatalf("reply painted %v times while the message was being persisted, want exactly once", seen)
101+ }
102+}
103+
104+// Race 2: after a text+tool reply, the tool result lands in the transcript
105+// while the pacer is still painting the text. The reply must stay single.
106+func TestStreamReplyNotDuplicatedWhenToolResultArrivesEarly(t *testing.T) {
107+ const reply = "let me check that file before answering the question properly"
108+ toolCall := provider.ToolCallBlock{ID: "call-1", Name: "echo", Arguments: json.RawMessage(`{}`)}
109+ client := &scriptedTurnsClient{turns: [][]provider.Event{
110+ textTurn(reply, provider.StopToolUse, toolCall),
111+ textTurn("done", provider.StopEnd),
112+ }}
113+ i, ag := newStreamRaceInteractive(t, client, core.Registry{"echo": echoTool{}})
114+
115+ var seen []int
116+ sawLive := false
117+ ag.OnMessageAppended = func(m provider.Message) {
118+ if m.Role != provider.RoleTool {
119+ return
120+ }
121+ // Tool result appended; the pacer has painted only part of the reply.
122+ i.mu.Lock()
123+ drainTicks(&i.stream, 3)
124+ sawLive = sawLive || i.stream.Active()
125+ i.mu.Unlock()
126+ seen = append(seen, countVisible(i, "let me"))
127+ }
128+ if err := ag.Prompt(context.Background(), "question", nil, i.handleEvent); err != nil {
129+ t.Fatal(err)
130+ }
131+ if !sawLive {
132+ t.Fatal("test setup: pacer finished before the tool result was appended")
133+ }
134+ if len(seen) != 1 || seen[0] != 1 {
135+ t.Fatalf("reply painted %v times after the tool result arrived, want exactly once", seen)
136+ }
137+}
138+
139+// Esc usually surfaces as a context error, not StopAborted. Buffered text
140+// must stop at once instead of typing on and then vanishing.
141+func TestStreamStopsImmediatelyOnCancel(t *testing.T) {
142+ i := &Interactive{view: &tui.View{Theme: tui.Dark}, toolCalls: map[string]*tui.ToolCallView{}}
143+ i.handleEvent(core.EvAssistantStart{})
144+ i.handleEvent(core.EvTextDelta{Delta: strings.Repeat("x", 500)})
145+ i.handleEvent(core.EvTurnEnd{Stop: provider.StopError, Err: context.Canceled})
146+ if i.stream.Active() || i.stream.Text() != "" {
147+ t.Fatalf("stream still active after cancel: active=%t text=%d", i.stream.Active(), len(i.stream.Text()))
148+ }
149+}
150+
151+// Replacing the agent (/model, /login) while text drains must drop the old
152+// agent's transcript anchor.
153+func TestStreamResetOnAgentReplacement(t *testing.T) {
154+ i := &Interactive{view: &tui.View{Theme: tui.Dark}, toolCalls: map[string]*tui.ToolCallView{}}
155+ i.stream.Start(3, 7)
156+ i.stream.Push("draining")
157+ i.stream.Finish()
158+ i.prepareReplacementAgentLocked(nil)
159+ if i.stream.Active() {
160+ t.Fatal("stream anchor survived an agent replacement")
161+ }
162+}
163+
164+// After the pacer drains, the transcript shows the reply exactly once and
165+// the live block is gone.
166+func TestStreamRevealsTranscriptWhenPacerDrains(t *testing.T) {
167+ const reply = "one two three four five six seven eight nine ten"
168+ client := &scriptedTurnsClient{turns: [][]provider.Event{textTurn(reply, provider.StopEnd)}}
169+ i, ag := newStreamRaceInteractive(t, client, nil)
170+ if err := ag.Prompt(context.Background(), "question", nil, i.handleEvent); err != nil {
171+ t.Fatal(err)
172+ }
173+
174+ i.mu.Lock()
175+ drainTicks(&i.stream, 3)
176+ active := i.stream.Active()
177+ i.mu.Unlock()
178+ if !active {
179+ t.Fatal("test setup: stream drained after one tick")
180+ }
181+ if got := countVisible(i, "one two"); got != 1 {
182+ t.Fatalf("reply painted %d times before draining, want 1", got)
183+ }
184+
185+ i.mu.Lock()
186+ for i.stream.Active() {
187+ i.stream.Tick()
188+ }
189+ i.mu.Unlock()
190+ if got := countVisible(i, reply); got != 1 {
191+ t.Fatalf("reply painted %d times after draining, want 1", got)
192+ }
193+}
194+
195+// A backlog far larger than the minimum rate is painted within a bounded
196+// number of ticks, so a fast provider never leaves the UI trailing far behind.
197+func TestStreamPacerCatchesUpWithLargeBacklog(t *testing.T) {
198+ var s streamPresenter
199+ s.Start(-1, 0)
200+ s.Push(strings.Repeat("x", 20000))
201+ s.Finish()
202+ ticks := 0
203+ for s.Active() {
204+ s.Tick()
205+ ticks++
206+ }
207+ if limit := paintCatchUpTicks * 8; ticks > limit {
208+ t.Fatalf("drained a 20k-rune backlog in %d ticks, want <= %d", ticks, limit)
209+ }
210+}
211+
212+// A transcript replaced by a shorter one (compaction, /clear) must never be
213+// clipped by an anchor that described the old transcript.
214+func TestStreamAnchorIgnoredAfterTranscriptShrinks(t *testing.T) {
215+ var s streamPresenter
216+ s.Start(10, 42)
217+ s.Push("text")
218+ if _, _, ok := s.transcriptLimit(3); ok {
219+ t.Fatal("anchor clipped a transcript shorter than itself")
220+ }
221+ if limit, rev, ok := s.transcriptLimit(12); !ok || limit != 10 || rev != 42 {
222+ t.Fatalf("transcriptLimit(12) = %d, %d, %t; want 10, 42, true", limit, rev, ok)
223+ }
224+}
@@ -0,0 +1,224 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+ "github.com/bnema/zut/packages/tui"
12+)
13+
14+// scriptedTurnsClient replays one scripted event list per provider request.
15+type scriptedTurnsClient struct {
16+ turns [][]provider.Event
17+ call int
18+}
19+
20+func (c *scriptedTurnsClient) Name() string { return "scripted" }
21+
22+func (c *scriptedTurnsClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
23+ events := c.turns[min(c.call, len(c.turns)-1)]
24+ c.call++
25+ out := make(chan provider.Event, len(events))
26+ for _, ev := range events {
27+ out <- ev
28+ }
29+ close(out)
30+ return out, nil
31+}
32+
33+type echoTool struct{}
34+
35+func (echoTool) Name() string { return "echo" }
36+func (echoTool) Description() string { return "echo" }
37+func (echoTool) Schema() json.RawMessage {
38+ return json.RawMessage(`{"type":"object"}`)
39+}
40+func (echoTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
41+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "tool output"}}}, nil
42+}
43+
44+func textTurn(text string, stop provider.StopReason, extra ...provider.Content) []provider.Event {
45+ content := append([]provider.Content{provider.TextBlock{Text: text}}, extra...)
46+ var events []provider.Event
47+ // Fat chunks like the Anthropic OAuth path: the provider is far ahead of
48+ // the pacer when the message completes.
49+ for chunk := range strings.SplitSeq(text, " ") {
50+ events = append(events, provider.EventTextDelta{Delta: chunk + " "})
51+ }
52+ return append(events, provider.EventDone{Stop: stop, Message: provider.Message{Role: provider.RoleAssistant, Content: content}})
53+}
54+
55+// countVisible reports how many times text appears in the visible chat.
56+func countVisible(i *Interactive, text string) int {
57+ i.mu.Lock()
58+ defer i.mu.Unlock()
59+ return strings.Count(stripANSIBytes(strings.Join(i.buildChatLocked(120), "\n")), text)
60+}
61+
62+// newStreamRaceInteractive wires an agent to an interactive view without
63+// the pacer goroutine, so the test controls exactly when text is painted.
64+func newStreamRaceInteractive(t *testing.T, client provider.Client, tools core.Registry) (*Interactive, *core.Agent) {
65+ t.Helper()
66+ ag := core.NewAgent(client, "test-model", "", tools)
67+ i := &Interactive{
68+ agent: ag,
69+ view: &tui.View{Theme: tui.Dark},
70+ toolCalls: map[string]*tui.ToolCallView{},
71+ dirty: make(chan struct{}, 1),
72+ busy: true,
73+ }
74+ return i, ag
75+}
76+
77+// Race 1: the agent appends the finished reply and persists it before the UI
78+// receives EvAssistantMessage. A frame drawn in that window must not show the
79+// reply both as a transcript message and as partial live text.
80+func TestStreamReplyNotDuplicatedWhileMessageIsPersisted(t *testing.T) {
81+ const reply = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu"
82+ client := &scriptedTurnsClient{turns: [][]provider.Event{textTurn(reply, provider.StopEnd)}}
83+ i, ag := newStreamRaceInteractive(t, client, nil)
84+
85+ var seen []int
86+ ag.OnMessageAppended = func(m provider.Message) {
87+ if m.Role != provider.RoleAssistant {
88+ return
89+ }
90+ // Paint part of the reply, as the pacer would have by now.
91+ i.mu.Lock()
92+ drainTicks(&i.stream, 3)
93+ i.mu.Unlock()
94+ seen = append(seen, countVisible(i, "alpha"))
95+ }
96+ if err := ag.Prompt(context.Background(), "question", nil, i.handleEvent); err != nil {
97+ t.Fatal(err)
98+ }
99+ if len(seen) != 1 || seen[0] != 1 {
100+ t.Fatalf("reply painted %v times while the message was being persisted, want exactly once", seen)
101+ }
102+}
103+
104+// Race 2: after a text+tool reply, the tool result lands in the transcript
105+// while the pacer is still painting the text. The reply must stay single.
106+func TestStreamReplyNotDuplicatedWhenToolResultArrivesEarly(t *testing.T) {
107+ const reply = "let me check that file before answering the question properly"
108+ toolCall := provider.ToolCallBlock{ID: "call-1", Name: "echo", Arguments: json.RawMessage(`{}`)}
109+ client := &scriptedTurnsClient{turns: [][]provider.Event{
110+ textTurn(reply, provider.StopToolUse, toolCall),
111+ textTurn("done", provider.StopEnd),
112+ }}
113+ i, ag := newStreamRaceInteractive(t, client, core.Registry{"echo": echoTool{}})
114+
115+ var seen []int
116+ sawLive := false
117+ ag.OnMessageAppended = func(m provider.Message) {
118+ if m.Role != provider.RoleTool {
119+ return
120+ }
121+ // Tool result appended; the pacer has painted only part of the reply.
122+ i.mu.Lock()
123+ drainTicks(&i.stream, 3)
124+ sawLive = sawLive || i.stream.Active()
125+ i.mu.Unlock()
126+ seen = append(seen, countVisible(i, "let me"))
127+ }
128+ if err := ag.Prompt(context.Background(), "question", nil, i.handleEvent); err != nil {
129+ t.Fatal(err)
130+ }
131+ if !sawLive {
132+ t.Fatal("test setup: pacer finished before the tool result was appended")
133+ }
134+ if len(seen) != 1 || seen[0] != 1 {
135+ t.Fatalf("reply painted %v times after the tool result arrived, want exactly once", seen)
136+ }
137+}
138+
139+// Esc usually surfaces as a context error, not StopAborted. Buffered text
140+// must stop at once instead of typing on and then vanishing.
141+func TestStreamStopsImmediatelyOnCancel(t *testing.T) {
142+ i := &Interactive{view: &tui.View{Theme: tui.Dark}, toolCalls: map[string]*tui.ToolCallView{}}
143+ i.handleEvent(core.EvAssistantStart{})
144+ i.handleEvent(core.EvTextDelta{Delta: strings.Repeat("x", 500)})
145+ i.handleEvent(core.EvTurnEnd{Stop: provider.StopError, Err: context.Canceled})
146+ if i.stream.Active() || i.stream.Text() != "" {
147+ t.Fatalf("stream still active after cancel: active=%t text=%d", i.stream.Active(), len(i.stream.Text()))
148+ }
149+}
150+
151+// Replacing the agent (/model, /login) while text drains must drop the old
152+// agent's transcript anchor.
153+func TestStreamResetOnAgentReplacement(t *testing.T) {
154+ i := &Interactive{view: &tui.View{Theme: tui.Dark}, toolCalls: map[string]*tui.ToolCallView{}}
155+ i.stream.Start(3, 7)
156+ i.stream.Push("draining")
157+ i.stream.Finish()
158+ i.prepareReplacementAgentLocked(nil)
159+ if i.stream.Active() {
160+ t.Fatal("stream anchor survived an agent replacement")
161+ }
162+}
163+
164+// After the pacer drains, the transcript shows the reply exactly once and
165+// the live block is gone.
166+func TestStreamRevealsTranscriptWhenPacerDrains(t *testing.T) {
167+ const reply = "one two three four five six seven eight nine ten"
168+ client := &scriptedTurnsClient{turns: [][]provider.Event{textTurn(reply, provider.StopEnd)}}
169+ i, ag := newStreamRaceInteractive(t, client, nil)
170+ if err := ag.Prompt(context.Background(), "question", nil, i.handleEvent); err != nil {
171+ t.Fatal(err)
172+ }
173+
174+ i.mu.Lock()
175+ drainTicks(&i.stream, 3)
176+ active := i.stream.Active()
177+ i.mu.Unlock()
178+ if !active {
179+ t.Fatal("test setup: stream drained after one tick")
180+ }
181+ if got := countVisible(i, "one two"); got != 1 {
182+ t.Fatalf("reply painted %d times before draining, want 1", got)
183+ }
184+
185+ i.mu.Lock()
186+ for i.stream.Active() {
187+ i.stream.Tick()
188+ }
189+ i.mu.Unlock()
190+ if got := countVisible(i, reply); got != 1 {
191+ t.Fatalf("reply painted %d times after draining, want 1", got)
192+ }
193+}
194+
195+// A backlog far larger than the minimum rate is painted within a bounded
196+// number of ticks, so a fast provider never leaves the UI trailing far behind.
197+func TestStreamPacerCatchesUpWithLargeBacklog(t *testing.T) {
198+ var s streamPresenter
199+ s.Start(-1, 0)
200+ s.Push(strings.Repeat("x", 20000))
201+ s.Finish()
202+ ticks := 0
203+ for s.Active() {
204+ s.Tick()
205+ ticks++
206+ }
207+ if limit := paintCatchUpTicks * 8; ticks > limit {
208+ t.Fatalf("drained a 20k-rune backlog in %d ticks, want <= %d", ticks, limit)
209+ }
210+}
211+
212+// A transcript replaced by a shorter one (compaction, /clear) must never be
213+// clipped by an anchor that described the old transcript.
214+func TestStreamAnchorIgnoredAfterTranscriptShrinks(t *testing.T) {
215+ var s streamPresenter
216+ s.Start(10, 42)
217+ s.Push("text")
218+ if _, _, ok := s.transcriptLimit(3); ok {
219+ t.Fatal("anchor clipped a transcript shorter than itself")
220+ }
221+ if limit, rev, ok := s.transcriptLimit(12); !ok || limit != 10 || rev != 42 {
222+ t.Fatalf("transcriptLimit(12) = %d, %d, %t; want 10, 42, true", limit, rev, ok)
223+ }
224+}
M packages/agent/modes/tool_gate_test.go +54 -80
@@ -1,101 +1,75 @@
11 package modes
22
3-import "testing"
4-
5-func newGateTestInteractive() *Interactive {
6- return &Interactive{
7- toolGate: map[string]int{},
8- dirty: make(chan struct{}, 1),
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// drainTicks paints n pacer ticks worth of text.
9+func drainTicks(s *streamPresenter, n int) {
10+ for range n {
11+ s.Tick()
912 }
1013 }
1114
12-// While paced text is still draining, a tool that arrives mid-stream
13-// must stay gated until the streaming buffer reaches the position
14-// captured when the tool started.
15+// A tool that arrives while paced text is still buffered stays hidden until
16+// the pacer has painted the text that preceded it.
1517 func TestToolGateHoldsToolUntilTextDrains(t *testing.T) {
16- i := newGateTestInteractive()
17-
18- // Simulate a turn that streamed 10 runes already with 20 more
19- // queued in the pacer when the tool call arrives.
20- i.streamOn = true
21- i.streaming.WriteString("0123456789") // 10 painted
22- i.streamPending = []rune("01234567890123456789")
23-
24- i.gateToolLocked("t1")
25- if got := i.toolGate["t1"]; got != 30 {
26- t.Fatalf("gate = %d, want 30 (10 painted + 20 pending)", got)
27- }
28-
29- if i.toolGateOpenLocked("t1") {
30- t.Fatal("tool should be gated while only 10/30 runes painted")
18+ var s streamPresenter
19+ s.Start(-1, 0)
20+ s.Push(strings.Repeat("a", 30))
21+ s.Tick() // paints paintPaceRate runes
22+
23+ s.Gate("t1")
24+ if s.GateOpen("t1") {
25+ t.Fatal("tool visible before the preceding text was painted")
3126 }
32-
33- // Drain halfway: still gated.
34- i.streaming.WriteString("0123456789") // 20 painted
35- if i.toolGateOpenLocked("t1") {
36- t.Fatal("tool should still be gated at 20/30")
27+ for s.paintedRunes < 30 {
28+ if s.GateOpen("t1") {
29+ t.Fatalf("tool visible at %d/30 painted runes", s.paintedRunes)
30+ }
31+ s.Tick()
3732 }
38-
39- // Reach the gate: now visible.
40- i.streaming.WriteString("0123456789") // 30 painted
41- if !i.toolGateOpenLocked("t1") {
42- t.Fatal("tool should be visible once streaming reaches the gate")
33+ if !s.GateOpen("t1") {
34+ t.Fatal("tool still hidden after the preceding text was painted")
4335 }
4436 }
4537
46-// A tool call on a turn with no active text stream shows immediately.
47-func TestToolGateOpenWhenNoStream(t *testing.T) {
48- i := newGateTestInteractive()
49- i.streamOn = false
50-
51- i.gateToolLocked("t1")
52- if got := i.toolGate["t1"]; got != 0 {
53- t.Fatalf("gate = %d, want 0 for non-streaming turn", got)
54- }
55- if !i.toolGateOpenLocked("t1") {
56- t.Fatal("tool should be visible immediately when no text is streaming")
38+// Without live text a tool shows immediately.
39+func TestToolGateOpenWhenNotStreaming(t *testing.T) {
40+ var s streamPresenter
41+ s.Gate("t1")
42+ if !s.GateOpen("t1") {
43+ t.Fatal("tool hidden while no text is streaming")
5744 }
5845 }
5946
60-// First registration wins: a later EvToolCall must not move an
61-// existing gate (e.g. push it forward as more text arrives).
47+// First registration wins: a later EvToolCall must not move the gate.
6248 func TestToolGateFirstRegistrationWins(t *testing.T) {
63- i := newGateTestInteractive()
64- i.streamOn = true
65- i.streaming.WriteString("01234") // 5
66- i.streamPending = []rune("01234")
67-
68- i.gateToolLocked("t1") // gate 10
69- first := i.toolGate["t1"]
70-
71- // More text queues up, then the same tool is re-registered.
72- i.streamPending = append(i.streamPending, []rune("567890")...)
73- i.gateToolLocked("t1")
74-
75- if i.toolGate["t1"] != first {
76- t.Fatalf("gate moved from %d to %d; first registration must win", first, i.toolGate["t1"])
49+ var s streamPresenter
50+ s.Start(-1, 0)
51+ s.Push("0123456789")
52+ s.Gate("t1")
53+ first := s.gates["t1"]
54+ s.Push("more text")
55+ s.Gate("t1")
56+ if s.gates["t1"] != first {
57+ t.Fatalf("gate moved from %d to %d", first, s.gates["t1"])
7758 }
7859 }
7960
80-// Once streaming finalizes, the buffer resets to length 0; gates that
81-// were already satisfied must not re-hide their tools.
82-func TestOpenAllToolGatesSurvivesStreamReset(t *testing.T) {
83- i := newGateTestInteractive()
84- i.streamOn = true
85- i.streaming.WriteString("0123456789")
86- i.gateToolLocked("t1") // gate 10, satisfied
87-
88- if !i.toolGateOpenLocked("t1") {
89- t.Fatal("precondition: tool should be open before reset")
61+// Once the stream finishes, every gate is open again.
62+func TestToolGatesOpenAfterStreamEnds(t *testing.T) {
63+ var s streamPresenter
64+ s.Start(-1, 0)
65+ s.Push("0123456789")
66+ s.Gate("t1")
67+ s.Finish()
68+ drainTicks(&s, 10)
69+ if s.Active() {
70+ t.Fatal("stream still active after draining")
9071 }
91-
92- // Finalize the stream (mirrors resetStreamingStateLocked / pacer
93- // flush): buffer resets to 0 but gates are opened first.
94- i.openAllToolGatesLocked()
95- i.streaming.Reset()
96- i.streamOn = false
97-
98- if !i.toolGateOpenLocked("t1") {
99- t.Fatal("tool re-hidden after stream reset; gate should have been opened")
72+ if !s.GateOpen("t1") {
73+ t.Fatal("tool re-hidden after the stream ended")
10074 }
10175 }
@@ -1,101 +1,75 @@
1 package modes1 package modes
2 2
3-import "testing"3+import (
4-4+ "strings"
5-func newGateTestInteractive() *Interactive {5+ "testing"
6- return &Interactive{6+)
7- toolGate: map[string]int{},7+
8- dirty: make(chan struct{}, 1),8+// drainTicks paints n pacer ticks worth of text.
9+func drainTicks(s *streamPresenter, n int) {
10+ for range n {
11+ s.Tick()
9 }12 }
10 }13 }
11 14
12-// While paced text is still draining, a tool that arrives mid-stream15+// A tool that arrives while paced text is still buffered stays hidden until
13-// must stay gated until the streaming buffer reaches the position16+// the pacer has painted the text that preceded it.
14-// captured when the tool started.
15 func TestToolGateHoldsToolUntilTextDrains(t *testing.T) {17 func TestToolGateHoldsToolUntilTextDrains(t *testing.T) {
16- i := newGateTestInteractive()18+ var s streamPresenter
17-19+ s.Start(-1, 0)
18- // Simulate a turn that streamed 10 runes already with 20 more20+ s.Push(strings.Repeat("a", 30))
19- // queued in the pacer when the tool call arrives.21+ s.Tick() // paints paintPaceRate runes
20- i.streamOn = true22+
21- i.streaming.WriteString("0123456789") // 10 painted23+ s.Gate("t1")
22- i.streamPending = []rune("01234567890123456789")24+ if s.GateOpen("t1") {
23-25+ t.Fatal("tool visible before the preceding text was painted")
24- i.gateToolLocked("t1")
25- if got := i.toolGate["t1"]; got != 30 {
26- t.Fatalf("gate = %d, want 30 (10 painted + 20 pending)", got)
27- }
28-
29- if i.toolGateOpenLocked("t1") {
30- t.Fatal("tool should be gated while only 10/30 runes painted")
31 }26 }
32-27+ for s.paintedRunes < 30 {
33- // Drain halfway: still gated.28+ if s.GateOpen("t1") {
34- i.streaming.WriteString("0123456789") // 20 painted29+ t.Fatalf("tool visible at %d/30 painted runes", s.paintedRunes)
35- if i.toolGateOpenLocked("t1") {30+ }
36- t.Fatal("tool should still be gated at 20/30")31+ s.Tick()
37 }32 }
38-33+ if !s.GateOpen("t1") {
39- // Reach the gate: now visible.34+ t.Fatal("tool still hidden after the preceding text was painted")
40- i.streaming.WriteString("0123456789") // 30 painted
41- if !i.toolGateOpenLocked("t1") {
42- t.Fatal("tool should be visible once streaming reaches the gate")
43 }35 }
44 }36 }
45 37
46-// A tool call on a turn with no active text stream shows immediately.38+// Without live text a tool shows immediately.
47-func TestToolGateOpenWhenNoStream(t *testing.T) {39+func TestToolGateOpenWhenNotStreaming(t *testing.T) {
48- i := newGateTestInteractive()40+ var s streamPresenter
49- i.streamOn = false41+ s.Gate("t1")
50-42+ if !s.GateOpen("t1") {
51- i.gateToolLocked("t1")43+ t.Fatal("tool hidden while no text is streaming")
52- if got := i.toolGate["t1"]; got != 0 {
53- t.Fatalf("gate = %d, want 0 for non-streaming turn", got)
54- }
55- if !i.toolGateOpenLocked("t1") {
56- t.Fatal("tool should be visible immediately when no text is streaming")
57 }44 }
58 }45 }
59 46
60-// First registration wins: a later EvToolCall must not move an47+// First registration wins: a later EvToolCall must not move the gate.
61-// existing gate (e.g. push it forward as more text arrives).
62 func TestToolGateFirstRegistrationWins(t *testing.T) {48 func TestToolGateFirstRegistrationWins(t *testing.T) {
63- i := newGateTestInteractive()49+ var s streamPresenter
64- i.streamOn = true50+ s.Start(-1, 0)
65- i.streaming.WriteString("01234") // 551+ s.Push("0123456789")
66- i.streamPending = []rune("01234")52+ s.Gate("t1")
67-53+ first := s.gates["t1"]
68- i.gateToolLocked("t1") // gate 1054+ s.Push("more text")
69- first := i.toolGate["t1"]55+ s.Gate("t1")
70-56+ if s.gates["t1"] != first {
71- // More text queues up, then the same tool is re-registered.57+ t.Fatalf("gate moved from %d to %d", first, s.gates["t1"])
72- i.streamPending = append(i.streamPending, []rune("567890")...)
73- i.gateToolLocked("t1")
74-
75- if i.toolGate["t1"] != first {
76- t.Fatalf("gate moved from %d to %d; first registration must win", first, i.toolGate["t1"])
77 }58 }
78 }59 }
79 60
80-// Once streaming finalizes, the buffer resets to length 0; gates that61+// Once the stream finishes, every gate is open again.
81-// were already satisfied must not re-hide their tools.62+func TestToolGatesOpenAfterStreamEnds(t *testing.T) {
82-func TestOpenAllToolGatesSurvivesStreamReset(t *testing.T) {63+ var s streamPresenter
83- i := newGateTestInteractive()64+ s.Start(-1, 0)
84- i.streamOn = true65+ s.Push("0123456789")
85- i.streaming.WriteString("0123456789")66+ s.Gate("t1")
86- i.gateToolLocked("t1") // gate 10, satisfied67+ s.Finish()
87-68+ drainTicks(&s, 10)
88- if !i.toolGateOpenLocked("t1") {69+ if s.Active() {
89- t.Fatal("precondition: tool should be open before reset")70+ t.Fatal("stream still active after draining")
90 }71 }
91-72+ if !s.GateOpen("t1") {
92- // Finalize the stream (mirrors resetStreamingStateLocked / pacer73+ t.Fatal("tool re-hidden after the stream ended")
93- // flush): buffer resets to 0 but gates are opened first.
94- i.openAllToolGatesLocked()
95- i.streaming.Reset()
96- i.streamOn = false
97-
98- if !i.toolGateOpenLocked("t1") {
99- t.Fatal("tool re-hidden after stream reset; gate should have been opened")
100 }74 }
101 }75 }
A packages/tui/live_text.go +81 -0
@@ -0,0 +1,81 @@
1+package tui
2+
3+import "strings"
4+
5+// assistantIndent is the left indent of assistant prose rows.
6+const assistantIndent = " "
7+
8+// liveTextCache keeps the rendered rows of the finished blocks of a
9+// streaming assistant reply. Streaming text only grows, so each frame
10+// renders just the unfinished tail instead of the whole reply.
11+type liveTextCache struct {
12+ width int
13+ theme uint64
14+ // src is the source prefix already rendered; it always ends at a
15+ // renderMarkdownRaw block boundary.
16+ src string
17+ rows []string
18+}
19+
20+// appendAssistantRows wraps rendered markdown rows to inner and indents them.
21+func appendAssistantRows(dst []string, rendered string, inner int) []string {
22+ for _, l := range strings.Split(rendered, "\n") {
23+ for _, w := range wrapANSILine(l, inner) {
24+ dst = append(dst, assistantIndent+w)
25+ }
26+ }
27+ return dst
28+}
29+
30+// renderAssistantText renders assistant prose as a finished transcript
31+// message. Live text must render identically, otherwise rows already pushed
32+// into terminal scrollback would no longer match the final message.
33+func renderAssistantText(text string, th Theme, width int) []string {
34+ inner := assistantBodyWidth(width - len(assistantIndent))
35+ return appendAssistantRows(nil, RenderMarkdown(strings.TrimLeft(text, "\n"), th, inner), inner)
36+}
37+
38+// liveTextRows renders the streaming reply, reusing cached rows for its
39+// finished blocks. The result is identical to renderAssistantText(text).
40+func (v *View) liveTextRows(text string, width int) []string {
41+ text = strings.TrimLeft(text, "\n")
42+ inner := assistantBodyWidth(width - len(assistantIndent))
43+ c := &v.liveText
44+ themeKey := toolThemeKey(v.Theme)
45+ if c.width != width || c.theme != themeKey || !strings.HasPrefix(text, c.src) {
46+ *c = liveTextCache{width: width, theme: themeKey}
47+ }
48+
49+ // Render only the uncached tail. The renderer reports where finished
50+ // blocks end, so the cache never has to re-parse markdown on its own.
51+ lines := strings.Split(text[len(c.src):], "\n")
52+ cutLine, cutOut := 0, 0
53+ raw := renderMarkdownRaw(lines, v.Theme, inner, func(line, outLen int) {
54+ cutLine, cutOut = line, outLen
55+ })
56+ if cutLine > 0 {
57+ c.rows = appendAssistantRows(c.rows, strings.TrimSuffix(raw[:cutOut], "\n"), inner)
58+ n := 0
59+ for _, l := range lines[:cutLine] {
60+ n += len(l) + 1
61+ }
62+ c.src = text[:len(c.src)+n]
63+ raw = raw[cutOut:]
64+ }
65+
66+ // RenderMarkdown trims trailing newlines from the whole reply. When the
67+ // tail has content, that trim only affects the tail; otherwise it also
68+ // drops the cached block's trailing empty rows.
69+ tail := strings.TrimRight(raw, "\n")
70+ if tail == "" {
71+ rows := c.rows
72+ for len(rows) > 1 && rows[len(rows)-1] == assistantIndent {
73+ rows = rows[:len(rows)-1]
74+ }
75+ if len(rows) == 0 {
76+ return []string{assistantIndent}
77+ }
78+ return append([]string(nil), rows...)
79+ }
80+ return appendAssistantRows(append(make([]string, 0, len(c.rows)+8), c.rows...), tail, inner)
81+}
@@ -0,0 +1,81 @@
1+package tui
2+
3+import "strings"
4+
5+// assistantIndent is the left indent of assistant prose rows.
6+const assistantIndent = " "
7+
8+// liveTextCache keeps the rendered rows of the finished blocks of a
9+// streaming assistant reply. Streaming text only grows, so each frame
10+// renders just the unfinished tail instead of the whole reply.
11+type liveTextCache struct {
12+ width int
13+ theme uint64
14+ // src is the source prefix already rendered; it always ends at a
15+ // renderMarkdownRaw block boundary.
16+ src string
17+ rows []string
18+}
19+
20+// appendAssistantRows wraps rendered markdown rows to inner and indents them.
21+func appendAssistantRows(dst []string, rendered string, inner int) []string {
22+ for _, l := range strings.Split(rendered, "\n") {
23+ for _, w := range wrapANSILine(l, inner) {
24+ dst = append(dst, assistantIndent+w)
25+ }
26+ }
27+ return dst
28+}
29+
30+// renderAssistantText renders assistant prose as a finished transcript
31+// message. Live text must render identically, otherwise rows already pushed
32+// into terminal scrollback would no longer match the final message.
33+func renderAssistantText(text string, th Theme, width int) []string {
34+ inner := assistantBodyWidth(width - len(assistantIndent))
35+ return appendAssistantRows(nil, RenderMarkdown(strings.TrimLeft(text, "\n"), th, inner), inner)
36+}
37+
38+// liveTextRows renders the streaming reply, reusing cached rows for its
39+// finished blocks. The result is identical to renderAssistantText(text).
40+func (v *View) liveTextRows(text string, width int) []string {
41+ text = strings.TrimLeft(text, "\n")
42+ inner := assistantBodyWidth(width - len(assistantIndent))
43+ c := &v.liveText
44+ themeKey := toolThemeKey(v.Theme)
45+ if c.width != width || c.theme != themeKey || !strings.HasPrefix(text, c.src) {
46+ *c = liveTextCache{width: width, theme: themeKey}
47+ }
48+
49+ // Render only the uncached tail. The renderer reports where finished
50+ // blocks end, so the cache never has to re-parse markdown on its own.
51+ lines := strings.Split(text[len(c.src):], "\n")
52+ cutLine, cutOut := 0, 0
53+ raw := renderMarkdownRaw(lines, v.Theme, inner, func(line, outLen int) {
54+ cutLine, cutOut = line, outLen
55+ })
56+ if cutLine > 0 {
57+ c.rows = appendAssistantRows(c.rows, strings.TrimSuffix(raw[:cutOut], "\n"), inner)
58+ n := 0
59+ for _, l := range lines[:cutLine] {
60+ n += len(l) + 1
61+ }
62+ c.src = text[:len(c.src)+n]
63+ raw = raw[cutOut:]
64+ }
65+
66+ // RenderMarkdown trims trailing newlines from the whole reply. When the
67+ // tail has content, that trim only affects the tail; otherwise it also
68+ // drops the cached block's trailing empty rows.
69+ tail := strings.TrimRight(raw, "\n")
70+ if tail == "" {
71+ rows := c.rows
72+ for len(rows) > 1 && rows[len(rows)-1] == assistantIndent {
73+ rows = rows[:len(rows)-1]
74+ }
75+ if len(rows) == 0 {
76+ return []string{assistantIndent}
77+ }
78+ return append([]string(nil), rows...)
79+ }
80+ return appendAssistantRows(append(make([]string, 0, len(c.rows)+8), c.rows...), tail, inner)
81+}
A packages/tui/live_text_test.go +108 -0
@@ -0,0 +1,108 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+const liveTextSample = "Intro paragraph with **bold** and `code`.\n\n" +
9+ "## Heading\n\n" +
10+ "- item one\n- item two\n - nested\n\n" +
11+ "```go\nfunc main() {\n\n\tprintln(\"hi\")\n}\n```\n\n" +
12+ "| a | b |\n| --- | ---: |\n| 1 | 2 |\n\n\n" +
13+ "> quoted line\n\n" +
14+ "1. first\n2. second\n\n" +
15+ "A long closing paragraph that should wrap across several rows when the terminal is narrow enough to force it.\n"
16+
17+// Live rows must equal the finished-message rows for every prefix of a reply,
18+// or rows already in terminal scrollback would diverge from the final text.
19+func TestLiveTextRowsMatchFinalRendering(t *testing.T) {
20+ corpus := map[string]string{
21+ "mixed": liveTextSample,
22+ "fence-like table": "| a | b |\n| --- | --- |\n```x | y\n\ntext\n```\n\nm\n\nafter",
23+ "crlf": "one\r\n\r\ntwo **b**\r\n\r\n- item\r\n",
24+ "blank spaces": "para one\n \n\t\t\npara two\n\n\n\npara three",
25+ "leading newlines": "\n\nstarts late\n\nand ends\n\n",
26+ "indented fence": "list:\n\n ```sh\n echo hi\n\n echo bye\n ```\n\ndone",
27+ "trailing table": "| h | i |\n| - | - |\n| 1 | 2 |\n\n\n",
28+ }
29+ for name, sample := range corpus {
30+ for _, width := range []int{10, 24, 80} {
31+ v := &View{Theme: Dark}
32+ for n := 1; n <= len(sample); n++ {
33+ prefix := sample[:n]
34+ got := strings.Join(v.liveTextRows(prefix, width), "\n")
35+ want := strings.Join(renderAssistantText(prefix, Dark, width), "\n")
36+ if got != want {
37+ t.Fatalf("%s, width %d, prefix %d %q:\nlive:\n%q\nfinal:\n%q", name, width, n, prefix, got, want)
38+ }
39+ }
40+ }
41+ }
42+}
43+
44+// Width and theme changes must not reuse rows rendered for other settings.
45+func TestLiveTextRowsResetOnWidthAndTheme(t *testing.T) {
46+ const text = "first **block**\n\n```go\nx := 1\n```\n\ntail"
47+ v := &View{Theme: Dark}
48+ v.liveTextRows(text, 80)
49+ if v.liveText.src == "" {
50+ t.Fatal("no finished block was cached")
51+ }
52+ if got, want := strings.Join(v.liveTextRows(text, 12), "\n"), strings.Join(renderAssistantText(text, Dark, 12), "\n"); got != want {
53+ t.Fatalf("width change reused stale rows:\n%q\nwant\n%q", got, want)
54+ }
55+ v.Theme = Light
56+ if got, want := strings.Join(v.liveTextRows(text, 12), "\n"), strings.Join(renderAssistantText(text, Light, 12), "\n"); got != want {
57+ t.Fatalf("theme change reused stale rows:\n%q\nwant\n%q", got, want)
58+ }
59+}
60+
61+// Render snapshots must not share the live-text cache with the owning view.
62+func TestCloneForRenderIsolatesLiveTextCache(t *testing.T) {
63+ v := &View{Theme: Dark}
64+ v.liveTextRows("block one\n\nblock two\n\n", 80)
65+ clone := v.CloneForRender()
66+ if clone.liveText.src != "" || clone.liveText.rows != nil {
67+ t.Fatal("clone shares the live-text cache")
68+ }
69+}
70+
71+// A new reply that does not extend the cached text starts a fresh cache.
72+func TestLiveTextRowsResetOnNewReply(t *testing.T) {
73+ v := &View{Theme: Dark}
74+ v.liveTextRows("first reply\n\nmore", 80)
75+ got := strings.Join(v.liveTextRows("second", 80), "\n")
76+ want := strings.Join(renderAssistantText("second", Dark, 80), "\n")
77+ if got != want {
78+ t.Fatalf("stale cache leaked into a new reply:\n%s", got)
79+ }
80+}
81+
82+func TestMarkdownBoundariesSkipOpenFence(t *testing.T) {
83+ lines := strings.Split("text\n\n```\ncode\n\nmore code\n", "\n")
84+ var got []int
85+ renderMarkdownRaw(lines, Dark, 80, func(line, _ int) { got = append(got, line) })
86+ if len(got) != 1 || got[0] != 2 {
87+ t.Fatalf("boundaries = %v, want [2] (none inside an open fence)", got)
88+ }
89+}
90+
91+func BenchmarkLiveTextStreaming(b *testing.B) {
92+ text := strings.Repeat(liveTextSample, 8)
93+ for b.Loop() {
94+ v := &View{Theme: Dark}
95+ for n := 64; n <= len(text); n += 64 {
96+ v.liveTextRows(text[:n], 100)
97+ }
98+ }
99+}
100+
101+func BenchmarkFullRerenderStreaming(b *testing.B) {
102+ text := strings.Repeat(liveTextSample, 8)
103+ for b.Loop() {
104+ for n := 64; n <= len(text); n += 64 {
105+ renderAssistantText(text[:n], Dark, 100)
106+ }
107+ }
108+}
@@ -0,0 +1,108 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+const liveTextSample = "Intro paragraph with **bold** and `code`.\n\n" +
9+ "## Heading\n\n" +
10+ "- item one\n- item two\n - nested\n\n" +
11+ "```go\nfunc main() {\n\n\tprintln(\"hi\")\n}\n```\n\n" +
12+ "| a | b |\n| --- | ---: |\n| 1 | 2 |\n\n\n" +
13+ "> quoted line\n\n" +
14+ "1. first\n2. second\n\n" +
15+ "A long closing paragraph that should wrap across several rows when the terminal is narrow enough to force it.\n"
16+
17+// Live rows must equal the finished-message rows for every prefix of a reply,
18+// or rows already in terminal scrollback would diverge from the final text.
19+func TestLiveTextRowsMatchFinalRendering(t *testing.T) {
20+ corpus := map[string]string{
21+ "mixed": liveTextSample,
22+ "fence-like table": "| a | b |\n| --- | --- |\n```x | y\n\ntext\n```\n\nm\n\nafter",
23+ "crlf": "one\r\n\r\ntwo **b**\r\n\r\n- item\r\n",
24+ "blank spaces": "para one\n \n\t\t\npara two\n\n\n\npara three",
25+ "leading newlines": "\n\nstarts late\n\nand ends\n\n",
26+ "indented fence": "list:\n\n ```sh\n echo hi\n\n echo bye\n ```\n\ndone",
27+ "trailing table": "| h | i |\n| - | - |\n| 1 | 2 |\n\n\n",
28+ }
29+ for name, sample := range corpus {
30+ for _, width := range []int{10, 24, 80} {
31+ v := &View{Theme: Dark}
32+ for n := 1; n <= len(sample); n++ {
33+ prefix := sample[:n]
34+ got := strings.Join(v.liveTextRows(prefix, width), "\n")
35+ want := strings.Join(renderAssistantText(prefix, Dark, width), "\n")
36+ if got != want {
37+ t.Fatalf("%s, width %d, prefix %d %q:\nlive:\n%q\nfinal:\n%q", name, width, n, prefix, got, want)
38+ }
39+ }
40+ }
41+ }
42+}
43+
44+// Width and theme changes must not reuse rows rendered for other settings.
45+func TestLiveTextRowsResetOnWidthAndTheme(t *testing.T) {
46+ const text = "first **block**\n\n```go\nx := 1\n```\n\ntail"
47+ v := &View{Theme: Dark}
48+ v.liveTextRows(text, 80)
49+ if v.liveText.src == "" {
50+ t.Fatal("no finished block was cached")
51+ }
52+ if got, want := strings.Join(v.liveTextRows(text, 12), "\n"), strings.Join(renderAssistantText(text, Dark, 12), "\n"); got != want {
53+ t.Fatalf("width change reused stale rows:\n%q\nwant\n%q", got, want)
54+ }
55+ v.Theme = Light
56+ if got, want := strings.Join(v.liveTextRows(text, 12), "\n"), strings.Join(renderAssistantText(text, Light, 12), "\n"); got != want {
57+ t.Fatalf("theme change reused stale rows:\n%q\nwant\n%q", got, want)
58+ }
59+}
60+
61+// Render snapshots must not share the live-text cache with the owning view.
62+func TestCloneForRenderIsolatesLiveTextCache(t *testing.T) {
63+ v := &View{Theme: Dark}
64+ v.liveTextRows("block one\n\nblock two\n\n", 80)
65+ clone := v.CloneForRender()
66+ if clone.liveText.src != "" || clone.liveText.rows != nil {
67+ t.Fatal("clone shares the live-text cache")
68+ }
69+}
70+
71+// A new reply that does not extend the cached text starts a fresh cache.
72+func TestLiveTextRowsResetOnNewReply(t *testing.T) {
73+ v := &View{Theme: Dark}
74+ v.liveTextRows("first reply\n\nmore", 80)
75+ got := strings.Join(v.liveTextRows("second", 80), "\n")
76+ want := strings.Join(renderAssistantText("second", Dark, 80), "\n")
77+ if got != want {
78+ t.Fatalf("stale cache leaked into a new reply:\n%s", got)
79+ }
80+}
81+
82+func TestMarkdownBoundariesSkipOpenFence(t *testing.T) {
83+ lines := strings.Split("text\n\n```\ncode\n\nmore code\n", "\n")
84+ var got []int
85+ renderMarkdownRaw(lines, Dark, 80, func(line, _ int) { got = append(got, line) })
86+ if len(got) != 1 || got[0] != 2 {
87+ t.Fatalf("boundaries = %v, want [2] (none inside an open fence)", got)
88+ }
89+}
90+
91+func BenchmarkLiveTextStreaming(b *testing.B) {
92+ text := strings.Repeat(liveTextSample, 8)
93+ for b.Loop() {
94+ v := &View{Theme: Dark}
95+ for n := 64; n <= len(text); n += 64 {
96+ v.liveTextRows(text[:n], 100)
97+ }
98+ }
99+}
100+
101+func BenchmarkFullRerenderStreaming(b *testing.B) {
102+ text := strings.Repeat(liveTextSample, 8)
103+ for b.Loop() {
104+ for n := 64; n <= len(text); n += 64 {
105+ renderAssistantText(text[:n], Dark, 100)
106+ }
107+ }
108+}
M packages/tui/markdown.go +26 -3
@@ -21,11 +21,23 @@ const FlushLeftSentinel = '\x1c'
2121 // width is used to draw horizontal rules (e.g. around code fences).
2222 // Pass 0 to use a reasonable fallback.
2323 func RenderMarkdown(src string, th Theme, width int) string {
24+ return strings.TrimRight(renderMarkdownRaw(strings.Split(src, "\n"), th, width, nil), "\n")
25+}
26+
27+// renderMarkdownRaw renders source lines without trimming trailing blank
28+// rows; every emitted row ends with "\n".
29+//
30+// If boundary is non-nil it is called with (line, outLen) for each line index
31+// where no block state carries over: the previous line was a plain blank
32+// line outside any fence or table. At such a point
33+// raw(lines) == raw(lines[:line]) + raw(lines[line:]), and outLen is the
34+// length of raw(lines[:line]). Live streaming uses these boundaries to cache
35+// finished blocks and re-render only the unfinished tail of a reply.
36+func renderMarkdownRaw(lines []string, th Theme, width int, boundary func(line, outLen int)) string {
2437 if width <= 0 {
2538 width = 80
2639 }
2740
28- lines := strings.Split(src, "\n")
2941 var out strings.Builder
3042 var fenceBuf strings.Builder
3143 inFence := false
@@ -59,10 +71,15 @@ func RenderMarkdown(src string, th Theme, width int) string {
5971 fenceBuf.Reset()
6072 }
6173
74+ prevBlank := false
6275 for idx := 0; idx < len(lines); idx++ {
76+ if prevBlank && boundary != nil {
77+ boundary(idx, out.Len())
78+ }
79+ prevBlank = false
6380 line := lines[idx]
6481 trim := strings.TrimLeft(line, " ")
65- if strings.HasPrefix(trim, "```") {
82+ if isMarkdownFence(line) {
6683 if inFence {
6784 flushFence()
6885 inFence = false
@@ -127,6 +144,7 @@ func RenderMarkdown(src string, th Theme, width int) string {
127144 out.WriteString(indent + th.FGColor(th.Accent, num+". ") + renderInline(body, th) + "\n")
128145 continue
129146 }
147+ prevBlank = strings.TrimSpace(line) == ""
130148 out.WriteString(renderInline(line, th) + "\n")
131149 }
132150 // Handle streaming / truncated input: the opening ``` arrived
@@ -135,7 +153,12 @@ func RenderMarkdown(src string, th Theme, width int) string {
135153 if inFence {
136154 flushFence()
137155 }
138- return strings.TrimRight(out.String(), "\n")
156+ return out.String()
157+}
158+
159+// isMarkdownFence reports whether line opens or closes a fenced code block.
160+func isMarkdownFence(line string) bool {
161+ return strings.HasPrefix(strings.TrimLeft(line, " "), "```")
139162 }
140163
141164 var (
@@ -21,11 +21,23 @@ const FlushLeftSentinel = '\x1c'
21 // width is used to draw horizontal rules (e.g. around code fences).21 // width is used to draw horizontal rules (e.g. around code fences).
22 // Pass 0 to use a reasonable fallback.22 // Pass 0 to use a reasonable fallback.
23 func RenderMarkdown(src string, th Theme, width int) string {23 func RenderMarkdown(src string, th Theme, width int) string {
24+ return strings.TrimRight(renderMarkdownRaw(strings.Split(src, "\n"), th, width, nil), "\n")
25+}
26+
27+// renderMarkdownRaw renders source lines without trimming trailing blank
28+// rows; every emitted row ends with "\n".
29+//
30+// If boundary is non-nil it is called with (line, outLen) for each line index
31+// where no block state carries over: the previous line was a plain blank
32+// line outside any fence or table. At such a point
33+// raw(lines) == raw(lines[:line]) + raw(lines[line:]), and outLen is the
34+// length of raw(lines[:line]). Live streaming uses these boundaries to cache
35+// finished blocks and re-render only the unfinished tail of a reply.
36+func renderMarkdownRaw(lines []string, th Theme, width int, boundary func(line, outLen int)) string {
24 if width <= 0 {37 if width <= 0 {
25 width = 8038 width = 80
26 }39 }
27 40
28- lines := strings.Split(src, "\n")
29 var out strings.Builder41 var out strings.Builder
30 var fenceBuf strings.Builder42 var fenceBuf strings.Builder
31 inFence := false43 inFence := false
@@ -59,10 +71,15 @@ func RenderMarkdown(src string, th Theme, width int) string {
59 fenceBuf.Reset()71 fenceBuf.Reset()
60 }72 }
61 73
74+ prevBlank := false
62 for idx := 0; idx < len(lines); idx++ {75 for idx := 0; idx < len(lines); idx++ {
76+ if prevBlank && boundary != nil {
77+ boundary(idx, out.Len())
78+ }
79+ prevBlank = false
63 line := lines[idx]80 line := lines[idx]
64 trim := strings.TrimLeft(line, " ")81 trim := strings.TrimLeft(line, " ")
65- if strings.HasPrefix(trim, "```") {82+ if isMarkdownFence(line) {
66 if inFence {83 if inFence {
67 flushFence()84 flushFence()
68 inFence = false85 inFence = false
@@ -127,6 +144,7 @@ func RenderMarkdown(src string, th Theme, width int) string {
127 out.WriteString(indent + th.FGColor(th.Accent, num+". ") + renderInline(body, th) + "\n")144 out.WriteString(indent + th.FGColor(th.Accent, num+". ") + renderInline(body, th) + "\n")
128 continue145 continue
129 }146 }
147+ prevBlank = strings.TrimSpace(line) == ""
130 out.WriteString(renderInline(line, th) + "\n")148 out.WriteString(renderInline(line, th) + "\n")
131 }149 }
132 // Handle streaming / truncated input: the opening ``` arrived150 // Handle streaming / truncated input: the opening ``` arrived
@@ -135,7 +153,12 @@ func RenderMarkdown(src string, th Theme, width int) string {
135 if inFence {153 if inFence {
136 flushFence()154 flushFence()
137 }155 }
138- return strings.TrimRight(out.String(), "\n")156+ return out.String()
157+}
158+
159+// isMarkdownFence reports whether line opens or closes a fenced code block.
160+func isMarkdownFence(line string) bool {
161+ return strings.HasPrefix(strings.TrimLeft(line, " "), "```")
139 }162 }
140 163
141 var (164 var (
M packages/tui/view.go +12 -29
@@ -199,6 +199,7 @@ type View struct {
199199 // tool results do not need to hash their payload on every frame.
200200 renderCache map[msgCacheKey][]string
201201 liveRenderCache map[liveToolCacheKey][]string
202+ liveText liveTextCache
202203 messageCache []messageRenderCacheEntry
203204 messageCacheStart int
204205 messageCacheRevision uint64
@@ -269,6 +270,7 @@ func (v *View) InvalidateRenderCache() {
269270 v.RenderCacheRevision++
270271 v.renderCache = nil
271272 v.liveRenderCache = nil
273+ v.liveText = liveTextCache{}
272274 v.messageCache = nil
273275 v.messageCacheStart = 0
274276 v.messageCacheRevision = 0
@@ -283,6 +285,8 @@ func (v *View) InvalidateRenderCache() {
283285 // state mutex without racing the next snapshot.
284286 func (v *View) CloneForRender() *View {
285287 clone := *v
288+ // The live-text cache grows in place; a snapshot must not share it.
289+ clone.liveText = liveTextCache{}
286290 clone.Messages = append([]provider.Message(nil), v.Messages...)
287291 clone.ToolCalls = append([]ToolCallView(nil), v.ToolCalls...)
288292 clone.StartupContextPaths = append([]string(nil), v.StartupContextPaths...)
@@ -451,15 +455,10 @@ func (v *View) BuildLive(width int) []string {
451455 themeKey := toolThemeKey(v.Theme)
452456 var out []string
453457 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
454- const indent = " "
455- inner := assistantBodyWidth(width - len(indent))
456- md := RenderMarkdown(v.Streaming, v.Theme, inner)
457- for _, l := range strings.Split(md, "\n") {
458- for _, w := range wrapANSILine(l, inner) {
459- out = append(out, indent+w)
460- }
461- }
462- // out = append(out, "")
458+ out = v.liveTextRows(v.Streaming, width)
459+ } else {
460+ // Release the finished reply's cached source and rows.
461+ v.liveText = liveTextCache{}
463462 }
464463 finalised := map[string]bool{}
465464 for _, m := range v.Messages {
@@ -652,7 +651,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
652651 out = append(out, "")
653652 }
654653 // Only render the streaming header/body when there's actual
655- // text to show. An empty streaming block (streamOn=true,
654+ // text to show. An empty streaming block (StreamingActive with
656655 // Streaming="") appears when a turn starts with a tool_use
657656 // block instead of text — in that case the live tool-call
658657 // overlay below is the real content and a naked "zut" bar
@@ -664,17 +663,8 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
664663 // don't suddenly reflow when the turn ends. No speaker header
665664 // is drawn; the indent matches the finalised assistant body in
666665 // renderMessage so the column stays consistent across the
667- // stream/finalise transition. Width is capped so ultra-wide
668- // terminals don't produce edge-to-edge code-fence rules or
669- // unreadably long prose lines.
670- const indent = " "
671- inner := assistantBodyWidth(width - len(indent))
672- md := RenderMarkdown(v.Streaming, v.Theme, inner)
673- for _, l := range strings.Split(md, "\n") {
674- for _, w := range wrapANSILine(l, inner) {
675- out = append(out, indent+w)
676- }
677- }
666+ // stream/finalise transition.
667+ out = append(out, v.liveTextRows(v.Streaming, width)...)
678668 out = append(out, "")
679669 }
680670 // Live tool-call overlay: keep the in-flight box visible after
@@ -1136,17 +1126,10 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
11361126 // small left indent so it visually aligns with tool box body
11371127 // content, but no "zut" header.
11381128 _ = turnOpen
1139- const indent = " "
1140- inner := assistantBodyWidth(width - len(indent))
11411129 for _, c := range m.Content {
11421130 switch b := c.(type) {
11431131 case provider.TextBlock:
1144- md := RenderMarkdown(strings.TrimLeft(b.Text, "\n"), v.Theme, inner)
1145- for _, l := range strings.Split(md, "\n") {
1146- for _, w := range wrapANSILine(l, inner) {
1147- lines = append(lines, indent+w)
1148- }
1149- }
1132+ lines = append(lines, renderAssistantText(b.Text, v.Theme, width)...)
11501133 case provider.ToolCallBlock:
11511134 // The whole box (top + body + bottom) is rendered by
11521135 // the matching tool_result message so a single
@@ -199,6 +199,7 @@ type View struct {
199 // tool results do not need to hash their payload on every frame.199 // tool results do not need to hash their payload on every frame.
200 renderCache map[msgCacheKey][]string200 renderCache map[msgCacheKey][]string
201 liveRenderCache map[liveToolCacheKey][]string201 liveRenderCache map[liveToolCacheKey][]string
202+ liveText liveTextCache
202 messageCache []messageRenderCacheEntry203 messageCache []messageRenderCacheEntry
203 messageCacheStart int204 messageCacheStart int
204 messageCacheRevision uint64205 messageCacheRevision uint64
@@ -269,6 +270,7 @@ func (v *View) InvalidateRenderCache() {
269 v.RenderCacheRevision++270 v.RenderCacheRevision++
270 v.renderCache = nil271 v.renderCache = nil
271 v.liveRenderCache = nil272 v.liveRenderCache = nil
273+ v.liveText = liveTextCache{}
272 v.messageCache = nil274 v.messageCache = nil
273 v.messageCacheStart = 0275 v.messageCacheStart = 0
274 v.messageCacheRevision = 0276 v.messageCacheRevision = 0
@@ -283,6 +285,8 @@ func (v *View) InvalidateRenderCache() {
283 // state mutex without racing the next snapshot.285 // state mutex without racing the next snapshot.
284 func (v *View) CloneForRender() *View {286 func (v *View) CloneForRender() *View {
285 clone := *v287 clone := *v
288+ // The live-text cache grows in place; a snapshot must not share it.
289+ clone.liveText = liveTextCache{}
286 clone.Messages = append([]provider.Message(nil), v.Messages...)290 clone.Messages = append([]provider.Message(nil), v.Messages...)
287 clone.ToolCalls = append([]ToolCallView(nil), v.ToolCalls...)291 clone.ToolCalls = append([]ToolCallView(nil), v.ToolCalls...)
288 clone.StartupContextPaths = append([]string(nil), v.StartupContextPaths...)292 clone.StartupContextPaths = append([]string(nil), v.StartupContextPaths...)
@@ -451,15 +455,10 @@ func (v *View) BuildLive(width int) []string {
451 themeKey := toolThemeKey(v.Theme)455 themeKey := toolThemeKey(v.Theme)
452 var out []string456 var out []string
453 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {457 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
454- const indent = " "458+ out = v.liveTextRows(v.Streaming, width)
455- inner := assistantBodyWidth(width - len(indent))459+ } else {
456- md := RenderMarkdown(v.Streaming, v.Theme, inner)460+ // Release the finished reply's cached source and rows.
457- for _, l := range strings.Split(md, "\n") {461+ v.liveText = liveTextCache{}
458- for _, w := range wrapANSILine(l, inner) {
459- out = append(out, indent+w)
460- }
461- }
462- // out = append(out, "")
463 }462 }
464 finalised := map[string]bool{}463 finalised := map[string]bool{}
465 for _, m := range v.Messages {464 for _, m := range v.Messages {
@@ -652,7 +651,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
652 out = append(out, "")651 out = append(out, "")
653 }652 }
654 // Only render the streaming header/body when there's actual653 // Only render the streaming header/body when there's actual
655- // text to show. An empty streaming block (streamOn=true,654+ // text to show. An empty streaming block (StreamingActive with
656 // Streaming="") appears when a turn starts with a tool_use655 // Streaming="") appears when a turn starts with a tool_use
657 // block instead of text — in that case the live tool-call656 // block instead of text — in that case the live tool-call
658 // overlay below is the real content and a naked "zut" bar657 // overlay below is the real content and a naked "zut" bar
@@ -664,17 +663,8 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
664 // don't suddenly reflow when the turn ends. No speaker header663 // don't suddenly reflow when the turn ends. No speaker header
665 // is drawn; the indent matches the finalised assistant body in664 // is drawn; the indent matches the finalised assistant body in
666 // renderMessage so the column stays consistent across the665 // renderMessage so the column stays consistent across the
667- // stream/finalise transition. Width is capped so ultra-wide666+ // stream/finalise transition.
668- // terminals don't produce edge-to-edge code-fence rules or667+ out = append(out, v.liveTextRows(v.Streaming, width)...)
669- // unreadably long prose lines.
670- const indent = " "
671- inner := assistantBodyWidth(width - len(indent))
672- md := RenderMarkdown(v.Streaming, v.Theme, inner)
673- for _, l := range strings.Split(md, "\n") {
674- for _, w := range wrapANSILine(l, inner) {
675- out = append(out, indent+w)
676- }
677- }
678 out = append(out, "")668 out = append(out, "")
679 }669 }
680 // Live tool-call overlay: keep the in-flight box visible after670 // Live tool-call overlay: keep the in-flight box visible after
@@ -1136,17 +1126,10 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
1136 // small left indent so it visually aligns with tool box body1126 // small left indent so it visually aligns with tool box body
1137 // content, but no "zut" header.1127 // content, but no "zut" header.
1138 _ = turnOpen1128 _ = turnOpen
1139- const indent = " "
1140- inner := assistantBodyWidth(width - len(indent))
1141 for _, c := range m.Content {1129 for _, c := range m.Content {
1142 switch b := c.(type) {1130 switch b := c.(type) {
1143 case provider.TextBlock:1131 case provider.TextBlock:
1144- md := RenderMarkdown(strings.TrimLeft(b.Text, "\n"), v.Theme, inner)1132+ lines = append(lines, renderAssistantText(b.Text, v.Theme, width)...)
1145- for _, l := range strings.Split(md, "\n") {
1146- for _, w := range wrapANSILine(l, inner) {
1147- lines = append(lines, indent+w)
1148- }
1149- }
1150 case provider.ToolCallBlock:1133 case provider.ToolCallBlock:
1151 // The whole box (top + body + bottom) is rendered by1134 // The whole box (top + body + bottom) is rendered by
1152 // the matching tool_result message so a single1135 // the matching tool_result message so a single

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