Skip to content

Commit acf333b

Browse files
committed
perf(tui): coalesce streamed text deltas to one frame
Every OnText delta was its own tea.Msg, so a fast provider (100+ tok/s) drove 100+ full Update->View cycles per second, each re-parsing the growing markdown — visible stutter over SSH and wasted CPU. Batch agentTextMsg deltas at the runtime sink over a ~16ms frame and forward them as a single message, decoupling render rate from token rate. Any non-text message flushes pending text first so ordering with tool-call / reasoning / row messages is preserved; the final agentResponseMsg (a tea.Cmd return, not a sink message) is safe because the model already drops deltas for an inactive runID.
1 parent e6bc357 commit acf333b

3 files changed

Lines changed: 222 additions & 1 deletion

File tree

‎internal/tui/coalesce.go‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package tui
2+
3+
import (
4+
"sync"
5+
"time"
6+
7+
tea "charm.land/bubbletea/v2"
8+
)
9+
10+
// streamCoalesceInterval is roughly one 60fps frame. Assistant-text deltas that
11+
// arrive within this window are merged into a single agentTextMsg, so the render
12+
// rate decouples from the token rate: a fast local model (100+ tok/s) no longer
13+
// forces 100+ full Update→View cycles (each re-parsing the growing markdown) per
14+
// second. Rendering stays smooth regardless of provider speed.
15+
const streamCoalesceInterval = 16 * time.Millisecond
16+
17+
// textCoalescer batches agentTextMsg deltas before forwarding them to the Bubble
18+
// Tea program. Any OTHER message flushes the pending text first, so ordering
19+
// between streamed prose and tool-call / reasoning / row / usage messages is
20+
// preserved. The turn's final agentResponseMsg does not pass through here (it is
21+
// a tea.Cmd return, not a sink message), but the model drops deltas whose runID
22+
// is no longer active, so a flush that races just past end-of-turn is harmless.
23+
//
24+
// Sink messages originate from the single agent goroutine and so arrive
25+
// serially; the only concurrent caller is the flush timer. The mutex guards the
26+
// buffer/timer against that one race.
27+
type textCoalescer struct {
28+
forward func(tea.Msg) // downstream sink (external sink + program.Send)
29+
30+
mu sync.Mutex
31+
buf []byte
32+
runID int
33+
timer *time.Timer
34+
}
35+
36+
func newTextCoalescer(forward func(tea.Msg)) *textCoalescer {
37+
return &textCoalescer{forward: forward}
38+
}
39+
40+
// send is the coalescing entry point installed as the RuntimeMessageSink.
41+
func (c *textCoalescer) send(msg tea.Msg) {
42+
text, ok := msg.(agentTextMsg)
43+
if !ok {
44+
// Non-text message: flush buffered text first (preserving order), then
45+
// forward it unchanged.
46+
c.flush()
47+
c.forward(msg)
48+
return
49+
}
50+
51+
c.mu.Lock()
52+
// A delta for a different run than the one buffered: flush the old run's text
53+
// before buffering the new run's. In practice runs are sequential (the prior
54+
// run's end already flushed via a non-text message), so this is belt-and-braces.
55+
if len(c.buf) > 0 && text.runID != c.runID {
56+
pending := c.drainLocked()
57+
c.mu.Unlock()
58+
c.forward(pending)
59+
c.mu.Lock()
60+
}
61+
c.runID = text.runID
62+
c.buf = append(c.buf, text.delta...)
63+
if c.timer == nil {
64+
c.timer = time.AfterFunc(streamCoalesceInterval, c.flush)
65+
}
66+
c.mu.Unlock()
67+
}
68+
69+
// flush forwards any buffered text as one agentTextMsg. Safe to call from the
70+
// timer goroutine and inline; a no-op when nothing is buffered.
71+
func (c *textCoalescer) flush() {
72+
c.mu.Lock()
73+
if len(c.buf) == 0 {
74+
c.mu.Unlock()
75+
return
76+
}
77+
msg := c.drainLocked()
78+
c.mu.Unlock()
79+
c.forward(msg)
80+
}
81+
82+
// drainLocked packages the buffer into an agentTextMsg and stops the timer. The
83+
// caller holds c.mu. string(c.buf) copies, so reusing the backing array via [:0]
84+
// is safe.
85+
func (c *textCoalescer) drainLocked() agentTextMsg {
86+
if c.timer != nil {
87+
c.timer.Stop()
88+
c.timer = nil
89+
}
90+
msg := agentTextMsg{runID: c.runID, delta: string(c.buf)}
91+
c.buf = c.buf[:0]
92+
return msg
93+
}

‎internal/tui/coalesce_test.go‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package tui
2+
3+
import (
4+
"sync"
5+
"testing"
6+
"time"
7+
8+
tea "charm.land/bubbletea/v2"
9+
)
10+
11+
// recorder is a thread-safe sink that captures forwarded messages in order.
12+
type recorder struct {
13+
mu sync.Mutex
14+
msgs []tea.Msg
15+
}
16+
17+
func (r *recorder) forward(msg tea.Msg) {
18+
r.mu.Lock()
19+
defer r.mu.Unlock()
20+
r.msgs = append(r.msgs, msg)
21+
}
22+
23+
func (r *recorder) snapshot() []tea.Msg {
24+
r.mu.Lock()
25+
defer r.mu.Unlock()
26+
out := make([]tea.Msg, len(r.msgs))
27+
copy(out, r.msgs)
28+
return out
29+
}
30+
31+
// Rapid deltas within one frame collapse into a single agentTextMsg carrying the
32+
// concatenated text.
33+
func TestCoalescerBatchesDeltas(t *testing.T) {
34+
rec := &recorder{}
35+
c := newTextCoalescer(rec.forward)
36+
37+
c.send(agentTextMsg{runID: 1, delta: "Hel"})
38+
c.send(agentTextMsg{runID: 1, delta: "lo, "})
39+
c.send(agentTextMsg{runID: 1, delta: "world"})
40+
41+
// Nothing forwarded yet — still buffered within the frame.
42+
if got := rec.snapshot(); len(got) != 0 {
43+
t.Fatalf("deltas should buffer, forwarded %d early: %#v", len(got), got)
44+
}
45+
46+
c.flush()
47+
48+
got := rec.snapshot()
49+
if len(got) != 1 {
50+
t.Fatalf("expected 1 coalesced message, got %d: %#v", len(got), got)
51+
}
52+
text, ok := got[0].(agentTextMsg)
53+
if !ok || text.delta != "Hello, world" || text.runID != 1 {
54+
t.Fatalf("coalesced message = %#v, want agentTextMsg{1, \"Hello, world\"}", got[0])
55+
}
56+
}
57+
58+
// A non-text message flushes buffered text first, so ordering (text before the
59+
// tool call it precedes) is preserved.
60+
func TestCoalescerFlushesTextBeforeOtherMessages(t *testing.T) {
61+
rec := &recorder{}
62+
c := newTextCoalescer(rec.forward)
63+
64+
c.send(agentTextMsg{runID: 1, delta: "about to run"})
65+
c.send(toolCallStreamStartMsg{runID: 1, id: "t1", name: "bash"})
66+
67+
got := rec.snapshot()
68+
if len(got) != 2 {
69+
t.Fatalf("expected text then tool-call, got %d: %#v", len(got), got)
70+
}
71+
if text, ok := got[0].(agentTextMsg); !ok || text.delta != "about to run" {
72+
t.Fatalf("first forwarded message must be the flushed text, got %#v", got[0])
73+
}
74+
if _, ok := got[1].(toolCallStreamStartMsg); !ok {
75+
t.Fatalf("second forwarded message must be the tool-call start, got %#v", got[1])
76+
}
77+
}
78+
79+
// A delta for a new run flushes the previous run's buffered text before buffering
80+
// the new run's, so text is never mis-attributed across runs.
81+
func TestCoalescerFlushesOnRunSwitch(t *testing.T) {
82+
rec := &recorder{}
83+
c := newTextCoalescer(rec.forward)
84+
85+
c.send(agentTextMsg{runID: 1, delta: "run one text"})
86+
c.send(agentTextMsg{runID: 2, delta: "run two text"})
87+
c.flush()
88+
89+
got := rec.snapshot()
90+
if len(got) != 2 {
91+
t.Fatalf("expected one message per run, got %d: %#v", len(got), got)
92+
}
93+
if first := got[0].(agentTextMsg); first.runID != 1 || first.delta != "run one text" {
94+
t.Fatalf("first message = %#v, want run 1 text flushed on switch", got[0])
95+
}
96+
if second := got[1].(agentTextMsg); second.runID != 2 || second.delta != "run two text" {
97+
t.Fatalf("second message = %#v, want run 2 text", got[1])
98+
}
99+
}
100+
101+
// The frame timer flushes buffered text on its own without an explicit flush or a
102+
// following message.
103+
func TestCoalescerTimerFlushes(t *testing.T) {
104+
rec := &recorder{}
105+
c := newTextCoalescer(rec.forward)
106+
107+
c.send(agentTextMsg{runID: 1, delta: "timer-driven"})
108+
109+
deadline := time.Now().Add(time.Second)
110+
for time.Now().Before(deadline) {
111+
if len(rec.snapshot()) > 0 {
112+
break
113+
}
114+
time.Sleep(streamCoalesceInterval)
115+
}
116+
117+
got := rec.snapshot()
118+
if len(got) != 1 {
119+
t.Fatalf("timer should have flushed exactly one message, got %d: %#v", len(got), got)
120+
}
121+
if text := got[0].(agentTextMsg); text.delta != "timer-driven" {
122+
t.Fatalf("timer-flushed message = %#v", got[0])
123+
}
124+
}

‎internal/tui/run.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,18 @@ func Run(ctx context.Context, options Options) int {
2525

2626
externalSink := options.RuntimeMessageSink
2727
var program *tea.Program
28-
options.RuntimeMessageSink = func(msg tea.Msg) {
28+
forward := func(msg tea.Msg) {
2929
if externalSink != nil {
3030
externalSink(msg)
3131
}
3232
if program != nil {
3333
program.Send(msg)
3434
}
3535
}
36+
// Coalesce streamed assistant-text deltas to ~one frame each so a fast provider
37+
// can't drive a full Update→View per token; every other message flushes pending
38+
// text first, keeping order intact.
39+
options.RuntimeMessageSink = newTextCoalescer(forward).send
3640
options.AltScreen = useAltScreen(options)
3741

3842
programOpts := []tea.ProgramOption{

0 commit comments

Comments
 (0)