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 Unverified
B5690EEEBB952194The 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) { | |||
| 149 | 149 | deadline := time.Now().Add(time.Second) | |
| 150 | 150 | for time.Now().Before(deadline) { | |
| 151 | 151 | i.mu.Lock() | |
| 152 | - | ready := i.pendingAlert != nil && !i.streamFlushPending && len(i.streamPending) == 0 | |
| 152 | + | ready := i.pendingAlert != nil && !i.stream.Active() | |
| 153 | 153 | i.mu.Unlock() | |
| 154 | 154 | if ready { | |
| 155 | 155 | i.redraw() | |
| @@ -166,8 +166,9 @@ func TestMainAlertWaitsForPacedText(t *testing.T) { | |||
| 166 | 166 | term := &alertTestTerminal{} | |
| 167 | 167 | i := NewInteractive(InteractiveConfig{Terminal: term}) | |
| 168 | 168 | 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() | |
| 171 | 172 | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"} | |
| 172 | 173 | ctx, cancel := context.WithCancel(context.Background()) | |
| 173 | 174 | defer cancel() | |
| @@ -176,7 +177,7 @@ func TestMainAlertWaitsForPacedText(t *testing.T) { | |||
| 176 | 177 | deadline := time.Now().Add(time.Second) | |
| 177 | 178 | for time.Now().Before(deadline) { | |
| 178 | 179 | i.mu.Lock() | |
| 179 | - | ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil | |
| 180 | + | ready := !i.stream.Active() && i.pendingAlert != nil | |
| 180 | 181 | i.mu.Unlock() | |
| 181 | 182 | if ready { | |
| 182 | 183 | i.redraw() | |
| @@ -188,10 +189,10 @@ func TestMainAlertWaitsForPacedText(t *testing.T) { | |||
| 188 | 189 | t.Fatalf("paced alert output = %q, want BEL after final redraw", got) | |
| 189 | 190 | } | |
| 190 | 191 | i.mu.Lock() | |
| 191 | - | on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert | |
| 192 | + | on, alert := i.stream.Active(), i.pendingAlert | |
| 192 | 193 | 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) | |
| 195 | 196 | } | |
| 196 | 197 | } | |
| 197 | 198 | ||
| @@ -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) == 0 | 152 | + | 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 = true | 170 | + | 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 != nil | 180 | + | 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.pendingAlert | 192 | + | 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) | |||
| 182 | 182 | } | |
| 183 | 183 | ||
| 184 | 184 | 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() | |
| 187 | 186 | 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) | |
| 190 | 189 | } | |
| 191 | 190 | ||
| 192 | 191 | 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 { | |||
| 117 | 117 | return false | |
| 118 | 118 | } | |
| 119 | 119 | 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 | |
| 122 | 121 | queued := len(i.queued) != 0 | |
| 123 | 122 | ag := i.agent | |
| 124 | 123 | pendingFork := i.pendingFork | |
| @@ -143,8 +142,7 @@ func (i *Interactive) canOpenSessionTree() bool { | |||
| 143 | 142 | } | |
| 144 | 143 | ||
| 145 | 144 | 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 | |
| 148 | 146 | queued := len(i.queued) != 0 | |
| 149 | 147 | ag := i.agent | |
| 150 | 148 | pendingFork := i.pendingFork | |
| @@ -168,8 +166,7 @@ func (i *Interactive) canCommitSessionTreeSelection() bool { | |||
| 168 | 166 | return false | |
| 169 | 167 | } | |
| 170 | 168 | 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 | |
| 173 | 170 | queued := len(i.queued) != 0 | |
| 174 | 171 | ag := i.agent | |
| 175 | 172 | i.mu.Unlock() | |
| @@ -117,8 +117,7 @@ func (i *Interactive) canArmSessionTreeEscape() bool { | |||||
| 117 | return false | 117 | 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) != 0 | 121 | queued := len(i.queued) != 0 | ||
| 123 | ag := i.agent | 122 | ag := i.agent | ||
| 124 | pendingFork := i.pendingFork | 123 | 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) != 0 | 146 | queued := len(i.queued) != 0 | ||
| 149 | ag := i.agent | 147 | ag := i.agent | ||
| 150 | pendingFork := i.pendingFork | 148 | pendingFork := i.pendingFork | ||
| @@ -168,8 +166,7 @@ func (i *Interactive) canCommitSessionTreeSelection() bool { | |||||
| 168 | return false | 166 | 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) != 0 | 170 | queued := len(i.queued) != 0 | ||
| 174 | ag := i.agent | 171 | ag := i.agent | ||
| 175 | i.mu.Unlock() | 172 | i.mu.Unlock() | ||
M packages/agent/modes/interactive.go +39 -123
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | - | "strings" | |
| 8 | 7 | "sync" | |
| 9 | 8 | "sync/atomic" | |
| 10 | 9 | "time" | |
| @@ -703,73 +702,48 @@ type Interactive struct { | |||
| 703 | 702 | // auto-subagents. Disable only removes these owned occurrences, leaving | |
| 704 | 703 | // identical text that came from the user's base prompt untouched. | |
| 705 | 704 | 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 | |
| 727 | 712 | // toolRenderRevision is monotonic for the lifetime of the interactive | |
| 728 | 713 | // view. Per-call counters would let unrelated tools alias the same rendered | |
| 729 | 714 | // cache frame when they reach the same local revision. | |
| 730 | 715 | 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 | |
| 773 | 747 | ||
| 774 | 748 | // prevChatLen and prevChatCols track the chat buffer's size at the | |
| 775 | 749 | // last redraw so that when content grows below the user's viewport | |
| @@ -1103,7 +1077,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 1103 | 1077 | ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)), | |
| 1104 | 1078 | rend: renderer, | |
| 1105 | 1079 | toolCalls: map[string]*tui.ToolCallView{}, | |
| 1106 | - | toolGate: map[string]int{}, | |
| 1080 | + | streamWake: make(chan struct{}, 1), | |
| 1107 | 1081 | dirty: make(chan struct{}, 8), | |
| 1108 | 1082 | modelRefresh: make(chan modelRefreshResult, 1), | |
| 1109 | 1083 | startupPreDone: make(chan startupPreResult, 1), | |
| @@ -2279,47 +2253,12 @@ type telegramHost struct{ iv *Interactive } | |||
| 2279 | 2253 | // formatInt is a tiny strconv.Itoa shim; keeps the handler above | |
| 2280 | 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 | 2256 | // resetTranscriptRenderLocked invalidates every render cache that assumes | |
| 2287 | 2257 | // the previous transcript remains structurally intact. Compaction is a | |
| 2288 | 2258 | // replacement, not an append: the flow renderer must repaint from the new | |
| 2289 | 2259 | // transcript rather than diff it against scrollback rows from the old one. | |
| 2290 | 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 | 2262 | // assistantMessageSideEffects runs the non-visual hooks attached to | |
| 2324 | 2263 | // EvAssistantMessage: the host-provided OnAssistant callback and the | |
| 2325 | 2264 | // telegram-bridge mirror. Called with i.mu held. | |
| @@ -2329,26 +2268,3 @@ type telegramHost struct{ iv *Interactive } | |||
| 2329 | 2268 | // the callbacks themselves must fire on message arrival so | |
| 2330 | 2269 | // downstream observers (session persistence, telegram, cost panels) | |
| 2331 | 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. | |
| @@ -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, leaving | 702 | // 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 []string | 704 | managedAutoSubagentsAddenda []string | ||
| 706 | - | streaming strings.Builder // what's currently painted on screen | 705 | + | // stream owns the paced live assistant text and decides how much of |
| 707 | - | streamOn bool | 706 | + | // the transcript is visible while that text is on screen. |
| 708 | - | pendingAlert *extproto.AlertRequest | 707 | + | stream streamPresenter |
| 709 | - | 708 | + | streamWake chan struct{} | |
| 710 | - | // streamPending is the runes buffered after each EvTextDelta that | 709 | + | pendingAlert *extproto.AlertRequest |
| 711 | - | // haven't yet been promoted into `streaming` for rendering. It | 710 | + | toolCalls map[string]*tui.ToolCallView |
| 712 | - | // exists because some provider paths (notably Anthropic via the | 711 | + | 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 interactive | 712 | // toolRenderRevision is monotonic for the lifetime of the interactive | ||
| 728 | // view. Per-call counters would let unrelated tools alias the same rendered | 713 | // 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 uint64 | 715 | toolRenderRevision uint64 | ||
| 731 | - | // toolGate records, per tool-call id, how many runes of paced | 716 | + | statusErr string |
| 732 | - | // assistant text must have drained into `streaming` before that | 717 | + | statusOK string |
| 733 | - | // tool block may appear. It exists to make stream ordering | 718 | + | goalStatus core.GoalStatus |
| 734 | - | // deterministic: a tool call can arrive from the provider while | 719 | + | planCurrent int |
| 735 | - | // the prose that logically precedes it is still being typed out | 720 | + | planTotal int |
| 736 | - | // by the pacer. Without gating, the tool block would render | 721 | + | planSnapshots map[string]core.PlanUpdate |
| 737 | - | // immediately while the intro paragraph keeps filling in below | 722 | + | planRevision uint64 |
| 738 | - | // it. We snapshot the total expected stream length (already | 723 | + | goalRun *goalContinuationRun |
| 739 | - | // streamed + still pending) at the moment the tool starts, and | 724 | + | reloadStatusSeq uint64 |
| 740 | - | // hold the block back until the pacer reaches it. | 725 | + | extStatuses map[string]map[string]extensionStatus |
| 741 | - | toolGate map[string]int | 726 | + | extWidgets map[string]map[string]extensionWidget |
| 742 | - | statusErr string | 727 | + | liveBlock []string // live streaming/tool progress rendered outside scrollback |
| 743 | - | statusOK string | 728 | + | helpBlock []string // rendered above the chat when /help was typed |
| 744 | - | goalStatus core.GoalStatus | 729 | + | sessionInfoBlocks []sessionInfoBlock |
| 745 | - | planCurrent int | 730 | + | cumUsage provider.Usage |
| 746 | - | planTotal int | 731 | + | codexUsage codexUsageState |
| 747 | - | planSnapshots map[string]core.PlanUpdate | 732 | + | lastCtxInput int // input_tokens of the most recent turn — approximates current context size |
| 748 | - | planRevision uint64 | 733 | + | busy bool |
| 749 | - | goalRun *goalContinuationRun | 734 | + | ctrlCExit bool |
| 750 | - | reloadStatusSeq uint64 | 735 | + | activity agentActivity |
| 751 | - | extStatuses map[string]map[string]extensionStatus | 736 | + | pendingIdleWork []func() |
| 752 | - | extWidgets map[string]map[string]extensionWidget | 737 | + | dirty chan struct{} |
| 753 | - | liveBlock []string // live streaming/tool progress rendered outside scrollback | 738 | + | renderScheduler atomic.Pointer[latestFrameScheduler] |
| 754 | - | helpBlock []string // rendered above the chat when /help was typed | 739 | + | renderRevision atomic.Uint64 |
| 755 | - | sessionInfoBlocks []sessionInfoBlock | 740 | + | renderOutsideLock bool |
| 756 | - | cumUsage provider.Usage | 741 | + | modelRefresh chan modelRefreshResult |
| 757 | - | codexUsage codexUsageState | 742 | + | modelRefreshing bool |
| 758 | - | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 743 | + | startupPreDone chan startupPreResult |
| 759 | - | busy bool | 744 | + | cancelTurn context.CancelFunc |
| 760 | - | ctrlCExit bool | 745 | + | scrollOffset int // rows from the bottom; 0 = pinned to latest |
| 761 | - | activity agentActivity | 746 | + | 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 the | 748 | // prevChatLen and prevChatCols track the chat buffer's size at the | ||
| 775 | // last redraw so that when content grows below the user's viewport | 749 | // 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 above | 2253 | // 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 assumes | 2256 | // resetTranscriptRenderLocked invalidates every render cache that assumes | ||
| 2287 | // the previous transcript remains structurally intact. Compaction is a | 2257 | // the previous transcript remains structurally intact. Compaction is a | ||
| 2288 | // replacement, not an append: the flow renderer must repaint from the new | 2258 | // 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 to | 2262 | // assistantMessageSideEffects runs the non-visual hooks attached to | ||
| 2324 | // EvAssistantMessage: the host-provided OnAssistant callback and the | 2263 | // 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 so | 2268 | // 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) { | |||
| 235 | 235 | } | |
| 236 | 236 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 237 | 237 | i.toolOrder = nil | |
| 238 | - | i.toolGate = map[string]int{} | |
| 238 | + | i.stream.Reset() | |
| 239 | 239 | i.statusErr = "" | |
| 240 | 240 | i.statusOK = "" | |
| 241 | 241 | i.helpBlock = nil | |
| @@ -437,7 +437,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 437 | 437 | i.mu.Lock() | |
| 438 | 438 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 439 | 439 | i.toolOrder = nil | |
| 440 | - | i.toolGate = map[string]int{} | |
| 440 | + | i.stream.Reset() | |
| 441 | 441 | i.helpBlock = nil | |
| 442 | 442 | i.sessionInfoBlocks = nil | |
| 443 | 443 | i.parkedTurn = 0 | |
| @@ -1022,8 +1022,7 @@ func (i *Interactive) canResumeSessionSelection() bool { | |||
| 1022 | 1022 | return false | |
| 1023 | 1023 | } | |
| 1024 | 1024 | 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 | |
| 1027 | 1026 | queued := len(i.queued) != 0 | |
| 1028 | 1027 | ag := i.agent | |
| 1029 | 1028 | 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 = nil | 237 | 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 = nil | 241 | 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 = nil | 439 | i.toolOrder = nil | ||
| 440 | - | i.toolGate = map[string]int{} | 440 | + | i.stream.Reset() |
| 441 | i.helpBlock = nil | 441 | i.helpBlock = nil | ||
| 442 | i.sessionInfoBlocks = nil | 442 | i.sessionInfoBlocks = nil | ||
| 443 | i.parkedTurn = 0 | 443 | i.parkedTurn = 0 | ||
| @@ -1022,8 +1022,7 @@ func (i *Interactive) canResumeSessionSelection() bool { | |||||
| 1022 | return false | 1022 | 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) != 0 | 1026 | queued := len(i.queued) != 0 | ||
| 1028 | ag := i.agent | 1027 | 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 | |||
| 261 | 261 | } | |
| 262 | 262 | i.statusErr = "" | |
| 263 | 263 | 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 | |
| 265 | 265 | // in the chat while compacting. The user just sees the spinner | |
| 266 | 266 | // and can keep typing / queue prompts. | |
| 267 | 267 | i.scrollOffset = 0 | |
| @@ -299,7 +299,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 299 | 299 | // Keep busy/compacting asserted while cleanup and queue selection run. | |
| 300 | 300 | // Completion updates can arrive in this window; clearing busy before | |
| 301 | 301 | // inspecting the queues lets them race a new turn or be stranded. | |
| 302 | - | i.resetStreamingStateLocked() | |
| 302 | + | i.stream.Reset() | |
| 303 | 303 | i.cancelTurn = nil | |
| 304 | 304 | i.autoCompacting = false | |
| 305 | 305 | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| @@ -354,7 +354,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 354 | 354 | i.lastCtxInput = estimateCompactedContextInput(msgs) | |
| 355 | 355 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 356 | 356 | i.toolOrder = nil | |
| 357 | - | i.toolGate = map[string]int{} | |
| 357 | + | i.stream.Reset() | |
| 358 | 358 | i.resetTranscriptRenderLocked() | |
| 359 | 359 | switch { | |
| 360 | 360 | 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 visible | 264 | + | // Do NOT start the stream presenter: the summary text should not be visible |
| 265 | // in the chat while compacting. The user just sees the spinner | 265 | // 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 = 0 | 267 | 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 before | 300 | // 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 = nil | 303 | i.cancelTurn = nil | ||
| 304 | i.autoCompacting = false | 304 | 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 = nil | 356 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "time" | |
| @@ -57,15 +58,10 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 57 | 58 | i.activity.apply(ev) | |
| 58 | 59 | switch e := ev.(type) { | |
| 59 | 60 | 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() | |
| 69 | 65 | // Clear the live tool-call overlay. Any tools from the | |
| 70 | 66 | // previous round are now fully folded into the transcript | |
| 71 | 67 | // (assistant tool_use block + tool role message with the | |
| @@ -76,28 +72,18 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 76 | 72 | // will populate fresh entries for this turn's tools. | |
| 77 | 73 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 78 | 74 | i.toolOrder = nil | |
| 79 | - | i.toolGate = map[string]int{} | |
| 80 | 75 | 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() | |
| 86 | 80 | 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. | |
| 90 | 83 | 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() | |
| 101 | 87 | case core.EvToolUseStart: | |
| 102 | 88 | // Live streaming: pre-create the view so the user sees the | |
| 103 | 89 | // tool call being composed in real time. Any subsequent | |
| @@ -112,7 +98,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 112 | 98 | i.bumpToolRevisionLocked(tc) | |
| 113 | 99 | i.toolCalls[e.ID] = tc | |
| 114 | 100 | i.toolOrder = append(i.toolOrder, e.ID) | |
| 115 | - | i.gateToolLocked(e.ID) | |
| 101 | + | i.stream.Gate(e.ID) | |
| 116 | 102 | } | |
| 117 | 103 | case core.EvToolUseArgs: | |
| 118 | 104 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| @@ -153,7 +139,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 153 | 139 | i.bumpToolRevisionLocked(tc) | |
| 154 | 140 | i.toolCalls[e.ID] = tc | |
| 155 | 141 | i.toolOrder = append(i.toolOrder, e.ID) | |
| 156 | - | i.gateToolLocked(e.ID) | |
| 142 | + | i.stream.Gate(e.ID) | |
| 157 | 143 | } | |
| 158 | 144 | case core.EvToolResult: | |
| 159 | 145 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| @@ -232,12 +218,19 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 232 | 218 | i.statusOK = "" | |
| 233 | 219 | } | |
| 234 | 220 | 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() | |
| 237 | 226 | i.statusErr = "" | |
| 238 | 227 | i.statusOK = "cancelled" | |
| 239 | 228 | 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 | 234 | if e.Stop == provider.StopLength { | |
| 242 | 235 | // The model hit its output-token cap mid-response, so the | |
| 243 | 236 | // 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 | 291 | func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) { | |
| 299 | 292 | // This runs inside the replacement commit, never during candidate builds. | |
| 300 | 293 | i.resetCodexUsageLocked() | |
| 294 | + | // A draining stream is anchored in the old agent's transcript. | |
| 295 | + | i.stream.Reset() | |
| 301 | 296 | if ag == nil { | |
| 302 | 297 | i.setWebSearchAvailable(false) | |
| 303 | 298 | 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-up | 61 | + | // Fires at the top of every model request, including follow-ups |
| 61 | - | // turns after tool use. Without this, the streaming buffer | 62 | + | // after tool use. The presenter anchors the transcript here so the |
| 62 | - | // is still marked off from the previous assistant message | 63 | + | // reply is never shown both as live text and as a finished message. |
| 63 | - | // and the final summary text pops in all at once instead | 64 | + | 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 the | 65 | // Clear the live tool-call overlay. Any tools from the | ||
| 70 | // previous round are now fully folded into the transcript | 66 | // previous round are now fully folded into the transcript | ||
| 71 | // (assistant tool_use block + tool role message with the | 67 | // (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 = nil | 74 | i.toolOrder = nil | ||
| 79 | - | i.toolGate = map[string]int{} | |||
| 80 | case core.EvTextDelta: | 75 | case core.EvTextDelta: | ||
| 81 | - | // Buffer into streamPending; the paintPace ticker drains | 76 | + | // 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 smooth | 77 | + | // 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 message | 81 | + | // Side effects read the final message, which is complete even |
| 88 | - | // arrival — they read the FINAL message content, which is | 82 | + | // 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 streamOn | 84 | + | // The presenter keeps the live text until the last rune is painted, |
| 92 | - | // true and mark flush pending; the paintPace ticker will | 85 | + | // 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 the | 88 | // Live streaming: pre-create the view so the user sees the | ||
| 103 | // tool call being composed in real time. Any subsequent | 89 | // 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] = tc | 99 | 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] = tc | 140 | 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 | return | 228 | 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 the | 235 | // The model hit its output-token cap mid-response, so the | ||
| 243 | // reply (often a long write/edit) is truncated. Surface it | 236 | // 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 | return | 298 | return | ||
M packages/agent/modes/interactive_host.go +2 -2
| @@ -118,7 +118,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||
| 118 | 118 | i.view.InvalidateRenderCache() | |
| 119 | 119 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 120 | 120 | i.toolOrder = nil | |
| 121 | - | i.toolGate = map[string]int{} | |
| 121 | + | i.stream.Reset() | |
| 122 | 122 | i.helpBlock = nil | |
| 123 | 123 | i.sessionInfoBlocks = nil | |
| 124 | 124 | i.extNotes = nil | |
| @@ -203,7 +203,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 203 | 203 | i.writeTerminalTitleLocked("") | |
| 204 | 204 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 205 | 205 | i.toolOrder = nil | |
| 206 | - | i.toolGate = map[string]int{} | |
| 206 | + | i.stream.Reset() | |
| 207 | 207 | i.helpBlock = nil | |
| 208 | 208 | i.sessionInfoBlocks = nil | |
| 209 | 209 | 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 = nil | 120 | i.toolOrder = nil | ||
| 121 | - | i.toolGate = map[string]int{} | 121 | + | i.stream.Reset() |
| 122 | i.helpBlock = nil | 122 | i.helpBlock = nil | ||
| 123 | i.sessionInfoBlocks = nil | 123 | i.sessionInfoBlocks = nil | ||
| 124 | i.extNotes = nil | 124 | 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 = nil | 205 | i.toolOrder = nil | ||
| 206 | - | i.toolGate = map[string]int{} | 206 | + | i.stream.Reset() |
| 207 | i.helpBlock = nil | 207 | i.helpBlock = nil | ||
| 208 | i.sessionInfoBlocks = nil | 208 | i.sessionInfoBlocks = nil | ||
| 209 | i.parkedTurn = 0 | 209 | i.parkedTurn = 0 | ||
M packages/agent/modes/interactive_render_test.go +24 -34
| @@ -66,11 +66,7 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) { | |||
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | 68 | 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)} | |
| 74 | 70 | ||
| 75 | 71 | i.handleEventForPresentation(core.EvToolUseStart{ID: "alpha", Name: "edit"}) | |
| 76 | 72 | alphaStart := i.toolCalls["alpha"].Revision | |
| @@ -89,11 +85,7 @@ func TestToolRenderRevisionsAreGloballyMonotonic(t *testing.T) { | |||
| 89 | 85 | ||
| 90 | 86 | func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) { | |
| 91 | 87 | 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)} | |
| 97 | 89 | i.renderScheduler.Store(scheduler) | |
| 98 | 90 | ||
| 99 | 91 | i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"}) | |
| @@ -143,33 +135,35 @@ func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) { | |||
| 143 | 135 | const finalText = "final message revealed after paced flush" | |
| 144 | 136 | ||
| 145 | 137 | 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 | + | }}) | |
| 156 | 142 | 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, | |
| 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 | 159 | i.mu.Lock() | |
| 165 | 160 | before := strings.Join(i.cachedChatLocked(80), "\n") | |
| 166 | - | i.streamFlushPending = false | |
| 161 | + | for i.stream.Active() { | |
| 162 | + | i.stream.Tick() | |
| 163 | + | } | |
| 167 | 164 | after := strings.Join(i.cachedChatLocked(80), "\n") | |
| 168 | 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 | 167 | if strings.Contains(before, finalText) { | |
| 174 | 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 | 175 | 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)} | |
| 187 | 177 | i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"}) | |
| 188 | 178 | select { | |
| 189 | 179 | 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"].Revision | 72 | 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 = false | 161 | + | 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 { | |||
| 34 | 34 | // A busy frame contains mutable streaming/tool state. Keep the stable | |
| 35 | 35 | // transcript cache, but never return a full-frame cache entry that would | |
| 36 | 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 | 38 | return i.buildChatLocked(cols) | |
| 39 | 39 | } | |
| 40 | 40 | key := i.chatCacheKeyLocked(cols) | |
| @@ -53,7 +53,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey { | |||
| 53 | 53 | if i.agent != nil { | |
| 54 | 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) < welcomeVersionDuration | |
| 56 | + | showVer := len(i.view.Messages) == 0 && !i.stream.Active() && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | |
| 57 | 57 | return chatCacheKey{ | |
| 58 | 58 | cols: cols, | |
| 59 | 59 | agentRev: rev, | |
| @@ -240,8 +240,16 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string { | |||
| 240 | 240 | } | |
| 241 | 241 | func (i *Interactive) buildChatLocked(cols int) []string { | |
| 242 | 242 | 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 | |
| 245 | 253 | } else { | |
| 246 | 254 | i.view.Messages = nil | |
| 247 | 255 | i.view.MessagesRevision = 0 | |
| @@ -255,39 +263,8 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 255 | 263 | // was anchored after. Clamp those anchors once so later turns still append | |
| 256 | 264 | // below the block instead of making it jump through the conversation. | |
| 257 | 265 | 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() | |
| 291 | 268 | // Live tool-call view: only shown while a turn is in flight. Once | |
| 292 | 269 | // the agent is idle, every tool call has already been folded into | |
| 293 | 270 | // the transcript (as assistant.ToolCallBlock + a tool-role message), | |
| @@ -302,7 +279,7 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 302 | 279 | // typing out. toolOrder is append-only in arrival order, | |
| 303 | 280 | // so once one tool is still gated, every later tool is too, | |
| 304 | 281 | // so stop here to avoid showing a tool out of sequence. | |
| 305 | - | if !i.toolGateOpenLocked(id) { | |
| 282 | + | if !i.stream.GateOpen(id) { | |
| 306 | 283 | break | |
| 307 | 284 | } | |
| 308 | 285 | if tc, ok := i.toolCalls[id]; ok { | |
| @@ -325,7 +302,7 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 325 | 302 | // no transcript yet. Disappears after the first message is sent. | |
| 326 | 303 | // The version suffix is shown for welcomeVersionDuration after | |
| 327 | 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 | 306 | showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | |
| 330 | 307 | chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...) | |
| 331 | 308 | } | |
| @@ -1054,7 +1031,7 @@ func (i *Interactive) redraw() { | |||
| 1054 | 1031 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 1055 | 1032 | } | |
| 1056 | 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 | 1035 | alert := *i.pendingAlert | |
| 1059 | 1036 | i.pendingAlert = nil | |
| 1060 | 1037 | 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 stable | 34 | // A busy frame contains mutable streaming/tool state. Keep the stable | ||
| 35 | // transcript cache, but never return a full-frame cache entry that would | 35 | // 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) < welcomeVersionDuration | 56 | + | 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 = nil | 254 | i.view.Messages = nil | ||
| 247 | i.view.MessagesRevision = 0 | 255 | 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 append | 263 | // 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 the | 266 | + | i.view.Streaming = i.stream.Text() |
| 259 | - | // buffer (i.e. EvAssistantMessage already fired but more runes | 267 | + | 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. Once | 268 | // 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 into | 269 | // 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 | break | 283 | 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 after | 303 | // 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) < welcomeVersionDuration | 306 | 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.pendingAlert | 1035 | alert := *i.pendingAlert | ||
| 1059 | i.pendingAlert = nil | 1036 | 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 ( | |||
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "strings" | |
| 9 | - | "time" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/bnema/zut/packages/agent/internal/orchestration" | |
| 12 | 11 | "github.com/bnema/zut/packages/agent/modes/telegram" | |
| @@ -995,7 +994,7 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in | |||
| 995 | 994 | } | |
| 996 | 995 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 997 | 996 | i.toolOrder = nil | |
| 998 | - | i.toolGate = map[string]int{} | |
| 997 | + | i.stream.Reset() | |
| 999 | 998 | i.extNotes = nil | |
| 1000 | 999 | if selection.restoreDraft { | |
| 1001 | 1000 | i.ed.SetValue(selection.draftText) | |
| @@ -1073,18 +1072,6 @@ func (i *Interactive) applyForkSelection(msgIdx int) { | |||
| 1073 | 1072 | func formatInt(n int) string { | |
| 1074 | 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 | 1075 | func (i *Interactive) resetTranscriptRenderLocked() { | |
| 1089 | 1076 | i.view.InvalidateRenderCache() | |
| 1090 | 1077 | i.chatCacheValid = false | |
| @@ -1094,35 +1081,6 @@ func (i *Interactive) resetTranscriptRenderLocked() { | |||
| 1094 | 1081 | i.prevScrollOffset = 0 | |
| 1095 | 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 | 1084 | func (i *Interactive) assistantMessageSideEffects(m provider.Message) { | |
| 1127 | 1085 | if i.cfg.OnAssistant != nil { | |
| 1128 | 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 | - | } | |
| @@ -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 = nil | 996 | i.toolOrder = nil | ||
| 998 | - | i.toolGate = map[string]int{} | 997 | + | i.stream.Reset() |
| 999 | i.extNotes = nil | 998 | 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 = false | 1077 | i.chatCacheValid = false | ||
| @@ -1094,35 +1081,6 @@ func (i *Interactive) resetTranscriptRenderLocked() { | |||||
| 1094 | i.prevScrollOffset = 0 | 1081 | 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 | |||
| 214 | 214 | i.cancelTurn = cancel | |
| 215 | 215 | i.statusErr = "" | |
| 216 | 216 | i.statusOK = "" | |
| 217 | - | i.streaming.Reset() | |
| 218 | - | i.streamOn = true | |
| 219 | 217 | i.pendingAlert = nil | |
| 220 | 218 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 221 | 219 | i.toolOrder = nil | |
| 222 | - | i.toolGate = map[string]int{} | |
| 220 | + | i.stream.Reset() | |
| 223 | 221 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |
| 224 | 222 | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| 225 | 223 | // 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 | 275 | // provider's final event; publishing idle before inspecting queues can | |
| 278 | 276 | // strand that summary in the core agent queue or start an overlapping | |
| 279 | 277 | // 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() | |
| 286 | 285 | } | |
| 287 | 286 | i.cancelTurn = nil | |
| 288 | 287 | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| @@ -463,7 +462,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 463 | 462 | // race the still-live stream frame; otherwise stale deltas can repaint | |
| 464 | 463 | // after the replacement and corrupt the scrollback renderer's model. | |
| 465 | 464 | if recoverContextOverflow || shouldAutoCompact { | |
| 466 | - | i.resetStreamingStateLocked() | |
| 465 | + | i.stream.Reset() | |
| 467 | 466 | } | |
| 468 | 467 | // A clean ordinary turn that owns a pending Esc return resumes the | |
| 469 | 468 | // 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 = cancel | 214 | 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 = nil | 217 | i.pendingAlert = nil | ||
| 220 | i.toolCalls = map[string]*tui.ToolCallView{} | 218 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 221 | i.toolOrder = nil | 219 | 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 them | 221 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | ||
| 224 | i.scrollOffset = 0 // jump back to the bottom on new turn | 222 | i.scrollOffset = 0 // jump back to the bottom on new turn | ||
| 225 | // Lift the resume tail cap once the user starts interacting. The | 223 | // 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 can | 275 | // provider's final event; publishing idle before inspecting queues can | ||
| 278 | // strand that summary in the core agent queue or start an overlapping | 276 | // strand that summary in the core agent queue or start an overlapping | ||
| 279 | // turn. | 277 | // turn. | ||
| 280 | - | // Don't touch streamPending / streamFlushPending here — the | 278 | + | // On success, let the pacer finish painting the final deltas; it |
| 281 | - | // pacer may still be draining the final deltas and needs to | 279 | + | // returns the presenter to idle when done. A cancelled turn stops |
| 282 | - | // paint them even though Prompt has returned. It will reset | 280 | + | // 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 = false | 283 | + | } else { |
| 284 | + | i.stream.Finish() | |||
| 286 | } | 285 | } | ||
| 287 | i.cancelTurn = nil | 286 | 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 repaint | 462 | // 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 the | 467 | // 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 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() | |
| 9 | 12 | } | |
| 10 | 13 | } | |
| 11 | 14 | ||
| 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. | |
| 15 | 17 | 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") | |
| 31 | 26 | } | |
| 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() | |
| 37 | 32 | } | |
| 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") | |
| 43 | 35 | } | |
| 44 | 36 | } | |
| 45 | 37 | ||
| 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") | |
| 57 | 44 | } | |
| 58 | 45 | } | |
| 59 | 46 | ||
| 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. | |
| 62 | 48 | 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"]) | |
| 77 | 58 | } | |
| 78 | 59 | } | |
| 79 | 60 | ||
| 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") | |
| 90 | 71 | } | |
| 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") | |
| 100 | 74 | } | |
| 101 | 75 | } | |
| @@ -1,101 +1,75 @@ | |||||
| 1 | package modes | 1 | 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-stream | 15 | + | // A tool that arrives while paced text is still buffered stays hidden until |
| 13 | - | // must stay gated until the streaming buffer reaches the position | 16 | + | // 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 more | 20 | + | 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 = true | 22 | + | |
| 21 | - | i.streaming.WriteString("0123456789") // 10 painted | 23 | + | 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 painted | 29 | + | 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 = false | 41 | + | 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 an | 47 | + | // 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 = true | 50 | + | s.Start(-1, 0) |
| 65 | - | i.streaming.WriteString("01234") // 5 | 51 | + | s.Push("0123456789") |
| 66 | - | i.streamPending = []rune("01234") | 52 | + | s.Gate("t1") |
| 67 | - | 53 | + | first := s.gates["t1"] | |
| 68 | - | i.gateToolLocked("t1") // gate 10 | 54 | + | 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 that | 61 | + | // 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 = true | 65 | + | s.Push("0123456789") |
| 85 | - | i.streaming.WriteString("0123456789") | 66 | + | s.Gate("t1") |
| 86 | - | i.gateToolLocked("t1") // gate 10, satisfied | 67 | + | 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 / pacer | 73 | + | 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' | |||
| 21 | 21 | // width is used to draw horizontal rules (e.g. around code fences). | |
| 22 | 22 | // Pass 0 to use a reasonable fallback. | |
| 23 | 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 | 37 | if width <= 0 { | |
| 25 | 38 | width = 80 | |
| 26 | 39 | } | |
| 27 | 40 | ||
| 28 | - | lines := strings.Split(src, "\n") | |
| 29 | 41 | var out strings.Builder | |
| 30 | 42 | var fenceBuf strings.Builder | |
| 31 | 43 | inFence := false | |
| @@ -59,10 +71,15 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||
| 59 | 71 | fenceBuf.Reset() | |
| 60 | 72 | } | |
| 61 | 73 | ||
| 74 | + | prevBlank := false | |
| 62 | 75 | for idx := 0; idx < len(lines); idx++ { | |
| 76 | + | if prevBlank && boundary != nil { | |
| 77 | + | boundary(idx, out.Len()) | |
| 78 | + | } | |
| 79 | + | prevBlank = false | |
| 63 | 80 | line := lines[idx] | |
| 64 | 81 | trim := strings.TrimLeft(line, " ") | |
| 65 | - | if strings.HasPrefix(trim, "```") { | |
| 82 | + | if isMarkdownFence(line) { | |
| 66 | 83 | if inFence { | |
| 67 | 84 | flushFence() | |
| 68 | 85 | inFence = false | |
| @@ -127,6 +144,7 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||
| 127 | 144 | out.WriteString(indent + th.FGColor(th.Accent, num+". ") + renderInline(body, th) + "\n") | |
| 128 | 145 | continue | |
| 129 | 146 | } | |
| 147 | + | prevBlank = strings.TrimSpace(line) == "" | |
| 130 | 148 | out.WriteString(renderInline(line, th) + "\n") | |
| 131 | 149 | } | |
| 132 | 150 | // Handle streaming / truncated input: the opening ``` arrived | |
| @@ -135,7 +153,12 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||
| 135 | 153 | if inFence { | |
| 136 | 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 | 164 | 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 = 80 | 38 | width = 80 | ||
| 26 | } | 39 | } | ||
| 27 | 40 | ||||
| 28 | - | lines := strings.Split(src, "\n") | |||
| 29 | var out strings.Builder | 41 | var out strings.Builder | ||
| 30 | var fenceBuf strings.Builder | 42 | var fenceBuf strings.Builder | ||
| 31 | inFence := false | 43 | 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 = false | 85 | 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 | continue | 145 | 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 ``` arrived | 150 | // 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 { | |||
| 199 | 199 | // tool results do not need to hash their payload on every frame. | |
| 200 | 200 | renderCache map[msgCacheKey][]string | |
| 201 | 201 | liveRenderCache map[liveToolCacheKey][]string | |
| 202 | + | liveText liveTextCache | |
| 202 | 203 | messageCache []messageRenderCacheEntry | |
| 203 | 204 | messageCacheStart int | |
| 204 | 205 | messageCacheRevision uint64 | |
| @@ -269,6 +270,7 @@ func (v *View) InvalidateRenderCache() { | |||
| 269 | 270 | v.RenderCacheRevision++ | |
| 270 | 271 | v.renderCache = nil | |
| 271 | 272 | v.liveRenderCache = nil | |
| 273 | + | v.liveText = liveTextCache{} | |
| 272 | 274 | v.messageCache = nil | |
| 273 | 275 | v.messageCacheStart = 0 | |
| 274 | 276 | v.messageCacheRevision = 0 | |
| @@ -283,6 +285,8 @@ func (v *View) InvalidateRenderCache() { | |||
| 283 | 285 | // state mutex without racing the next snapshot. | |
| 284 | 286 | func (v *View) CloneForRender() *View { | |
| 285 | 287 | clone := *v | |
| 288 | + | // The live-text cache grows in place; a snapshot must not share it. | |
| 289 | + | clone.liveText = liveTextCache{} | |
| 286 | 290 | clone.Messages = append([]provider.Message(nil), v.Messages...) | |
| 287 | 291 | clone.ToolCalls = append([]ToolCallView(nil), v.ToolCalls...) | |
| 288 | 292 | clone.StartupContextPaths = append([]string(nil), v.StartupContextPaths...) | |
| @@ -451,15 +455,10 @@ func (v *View) BuildLive(width int) []string { | |||
| 451 | 455 | themeKey := toolThemeKey(v.Theme) | |
| 452 | 456 | var out []string | |
| 453 | 457 | 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{} | |
| 463 | 462 | } | |
| 464 | 463 | finalised := map[string]bool{} | |
| 465 | 464 | for _, m := range v.Messages { | |
| @@ -652,7 +651,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 652 | 651 | out = append(out, "") | |
| 653 | 652 | } | |
| 654 | 653 | // 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 | 655 | // Streaming="") appears when a turn starts with a tool_use | |
| 657 | 656 | // block instead of text — in that case the live tool-call | |
| 658 | 657 | // overlay below is the real content and a naked "zut" bar | |
| @@ -664,17 +663,8 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 664 | 663 | // don't suddenly reflow when the turn ends. No speaker header | |
| 665 | 664 | // is drawn; the indent matches the finalised assistant body in | |
| 666 | 665 | // 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)...) | |
| 678 | 668 | out = append(out, "") | |
| 679 | 669 | } | |
| 680 | 670 | // 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 | 1126 | // small left indent so it visually aligns with tool box body | |
| 1137 | 1127 | // content, but no "zut" header. | |
| 1138 | 1128 | _ = turnOpen | |
| 1139 | - | const indent = " " | |
| 1140 | - | inner := assistantBodyWidth(width - len(indent)) | |
| 1141 | 1129 | for _, c := range m.Content { | |
| 1142 | 1130 | switch b := c.(type) { | |
| 1143 | 1131 | 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)...) | |
| 1150 | 1133 | case provider.ToolCallBlock: | |
| 1151 | 1134 | // The whole box (top + body + bottom) is rendered by | |
| 1152 | 1135 | // 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][]string | 200 | renderCache map[msgCacheKey][]string | ||
| 201 | liveRenderCache map[liveToolCacheKey][]string | 201 | liveRenderCache map[liveToolCacheKey][]string | ||
| 202 | + | liveText liveTextCache | |||
| 202 | messageCache []messageRenderCacheEntry | 203 | messageCache []messageRenderCacheEntry | ||
| 203 | messageCacheStart int | 204 | messageCacheStart int | ||
| 204 | messageCacheRevision uint64 | 205 | messageCacheRevision uint64 | ||
| @@ -269,6 +270,7 @@ func (v *View) InvalidateRenderCache() { | |||||
| 269 | v.RenderCacheRevision++ | 270 | v.RenderCacheRevision++ | ||
| 270 | v.renderCache = nil | 271 | v.renderCache = nil | ||
| 271 | v.liveRenderCache = nil | 272 | v.liveRenderCache = nil | ||
| 273 | + | v.liveText = liveTextCache{} | |||
| 272 | v.messageCache = nil | 274 | v.messageCache = nil | ||
| 273 | v.messageCacheStart = 0 | 275 | v.messageCacheStart = 0 | ||
| 274 | v.messageCacheRevision = 0 | 276 | 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 := *v | 287 | 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 []string | 456 | 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 actual | 653 | // 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_use | 655 | // Streaming="") appears when a turn starts with a tool_use | ||
| 657 | // block instead of text — in that case the live tool-call | 656 | // block instead of text — in that case the live tool-call | ||
| 658 | // overlay below is the real content and a naked "zut" bar | 657 | // 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 header | 663 | // don't suddenly reflow when the turn ends. No speaker header | ||
| 665 | // is drawn; the indent matches the finalised assistant body in | 664 | // is drawn; the indent matches the finalised assistant body in | ||
| 666 | // renderMessage so the column stays consistent across the | 665 | // renderMessage so the column stays consistent across the | ||
| 667 | - | // stream/finalise transition. Width is capped so ultra-wide | 666 | + | // stream/finalise transition. |
| 668 | - | // terminals don't produce edge-to-edge code-fence rules or | 667 | + | 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 after | 670 | // 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 body | 1126 | // small left indent so it visually aligns with tool box body | ||
| 1137 | // content, but no "zut" header. | 1127 | // content, but no "zut" header. | ||
| 1138 | _ = turnOpen | 1128 | _ = 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 by | 1134 | // The whole box (top + body + bottom) is rendered by | ||
| 1152 | // the matching tool_result message so a single | 1135 | // the matching tool_result message so a single | ||