Skip to content

Commit 5018ea3

Browse files
perf(tui): cache renders and calm the streaming refresh loop (#79)
* perf(tui): cache renders and calm the streaming refresh loop Spinner ticks no longer rebuild the transcript every frame; live step clocks coalesce at 250ms. Finished steps and finalized message blocks cache their layout, streaming replies glamour on the 80ms flush, and cleared-home sessions resume on 1–3. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tui): invalidate step cache on agent focus and resize live replies Step block cache now keys on agentSel and clears when tab-cycling chips. Resize re-glamours open reply segments so mid-stream cards reflow at the new width instead of keeping stale wrapped markdown. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5ba0bc3 commit 5018ea3

15 files changed

Lines changed: 418 additions & 67 deletions

‎AGENTS.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,11 @@ feat(tui): compact tool steps with Ctrl+E details toggle
139139
connection per run.
140140
- Streaming renders are coalesced into one flush per 80ms for
141141
performance; batching new redraw paths the same way keeps the TUI
142-
responsive.
142+
responsive. Live reply segments glamour-render on that flush (not only
143+
at turn end). The spinner animates the status line only — transcript
144+
rebuilds for live step clocks coalesce at 250ms (`tailClockFlushMsg`).
145+
Finished message blocks and done tool steps cache their render output;
146+
invalidate per message/step on expand, not the whole prefix.
143147
- The slash-completion popup holds key capture while open; typed keys
144148
must keep flowing to the input. Route keys through the popup first,
145149
then fall through to normal input handling.

‎internal/tui/cache.go‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package tui
2+
3+
// msgBlockCache holds one finalized transcript message's rendered block.
4+
// Ref line numbers are relative to the block start (offset at assembly time).
5+
type msgBlockCache struct {
6+
block string
7+
refs []stepRef
8+
width int
9+
valid bool
10+
}
11+
12+
func (m *Model) ensureMsgBlocks() {
13+
if len(m.msgBlocks) >= len(m.msgs) {
14+
return
15+
}
16+
m.msgBlocks = append(m.msgBlocks, make([]msgBlockCache, len(m.msgs)-len(m.msgBlocks))...)
17+
}
18+
19+
// invalidateMsgBlock drops one message's cached block (step expand, agent
20+
// focus, collapse — without rebuilding siblings).
21+
func (m *Model) invalidateMsgBlock(i int) {
22+
if i < 0 || i >= len(m.msgBlocks) {
23+
return
24+
}
25+
m.msgBlocks[i].valid = false
26+
}
27+
28+
// invalidateAllMsgBlocks drops every cached message block (resize, expand-all,
29+
// transcript wholesale replace).
30+
func (m *Model) invalidateAllMsgBlocks() {
31+
for i := range m.msgBlocks {
32+
m.msgBlocks[i].valid = false
33+
}
34+
m.convCount = -1
35+
}
36+
37+
func (m *Model) resetMsgBlocks() {
38+
m.msgBlocks = nil
39+
m.convCount = -1
40+
}
41+
42+
// msgBlockAt returns a cached or freshly rendered block for message i and
43+
// step refs offset to lineOffset in the full transcript.
44+
func (m *Model) msgBlockAt(i, lineOffset int) (string, []stepRef) {
45+
m.ensureMsgBlocks()
46+
bc := &m.msgBlocks[i]
47+
if !bc.valid || bc.width != m.vp.Width {
48+
s, r := m.renderMessage(m.msgs[i], i, 0)
49+
bc.block = m.clampLines(s)
50+
bc.refs = r
51+
bc.width = m.vp.Width
52+
bc.valid = true
53+
}
54+
return bc.block, offsetStepRefs(bc.refs, lineOffset)
55+
}
56+
57+
func clearStepBlockCache(s *step) {
58+
s.blockCache = ""
59+
s.blockRefs = nil
60+
s.blockWidth = 0
61+
}
62+
63+
func stepBlockCacheValid(s step, st *step, m *Model, expanded bool) bool {
64+
if !s.done || s.blockCache == "" || st == nil {
65+
return false
66+
}
67+
if st.name != s.name || st.done != s.done || st.result != s.result || st.expanded != s.expanded {
68+
return false
69+
}
70+
if st.agentSel != s.agentSel {
71+
return false
72+
}
73+
if s.blockWidth != m.vp.Width {
74+
return false
75+
}
76+
if s.blockExpanded != expanded {
77+
return false
78+
}
79+
if s.blockExpandAll != m.expandAll {
80+
return false
81+
}
82+
return true
83+
}

‎internal/tui/cache_test.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
"github.com/BackendStack21/bodek/internal/client"
9+
"github.com/charmbracelet/bubbles/spinner"
10+
)
11+
12+
func TestSpinnerTickDoesNotRebuildPrefixDuringStream(t *testing.T) {
13+
m := newTestModel()
14+
streamingTurn(m)
15+
m.refresh()
16+
if m.convCount != 1 {
17+
t.Fatalf("precondition convCount=%d, want 1", m.convCount)
18+
}
19+
prefix := m.convPrefix
20+
m.handleEvent(client.Event{Type: "token", Content: "more"})
21+
m.Update(spinner.TickMsg{})
22+
if m.convPrefix != prefix {
23+
t.Error("spinner tick rebuilt the finalized prefix during streaming")
24+
}
25+
}
26+
27+
func TestTailClockRefreshUpdatesLiveStep(t *testing.T) {
28+
m := newTestModel()
29+
streamingTurn(m)
30+
m.handleEvent(client.Event{Type: "tool_call", Name: "read_file", Data: `{"path":"x.go"}`})
31+
if i := m.cur(); i >= 0 && len(m.msgs[i].steps) > 0 {
32+
m.msgs[i].steps[0].started = time.Now().Add(-500 * time.Millisecond)
33+
}
34+
m.refresh()
35+
before := plain(m.vp.View())
36+
37+
if i := m.cur(); i >= 0 && len(m.msgs[i].steps) > 0 {
38+
m.msgs[i].steps[0].started = time.Now().Add(-1500 * time.Millisecond)
39+
}
40+
m.tailClockPending = true
41+
m.tailClockSeq = 1
42+
m.Update(tailClockFlushMsg{seq: 1})
43+
after := plain(m.vp.View())
44+
if before == after {
45+
t.Errorf("tail clock flush should refresh live step duration:\nbefore=%q\nafter=%q", before, after)
46+
}
47+
}
48+
49+
func TestStepBlockCacheReusesFinishedStep(t *testing.T) {
50+
m := newTestModel()
51+
m.msgs = append(m.msgs, message{role: roleAsst, streaming: false, steps: []step{
52+
{name: "shell", arg: "go test ./...", done: true, result: strings.Repeat("line\n", 40)},
53+
}})
54+
out1, _, _ := m.renderStep(m.msgs[0].steps[0], false, 0, 0, 0)
55+
out2, _, _ := m.renderStep(m.msgs[0].steps[0], false, 0, 0, 0)
56+
if out1 != out2 {
57+
t.Error("finished step should reuse block cache")
58+
}
59+
if m.msgs[0].steps[0].blockCache == "" {
60+
t.Error("renderStep should populate block cache for finished steps")
61+
}
62+
}
63+
64+
func TestHomeResumeKey(t *testing.T) {
65+
m := newTestModel()
66+
m.homePrompt = "cleared"
67+
m.homeSess = []client.Session{{ID: "sess-abc", Task: "fix login", Turns: 2, UpdatedAt: time.Now()}}
68+
if cmd := m.handleHomeResumeKey("1"); cmd == nil {
69+
t.Fatal("1 should resume the first recent session")
70+
}
71+
m.msgs = append(m.msgs, message{role: roleUser, content: "busy transcript"})
72+
if cmd := m.handleHomeResumeKey("1"); cmd != nil {
73+
t.Error("home resume must not fire while transcript has messages")
74+
}
75+
}
76+
77+
func TestMsgBlockPartialInvalidate(t *testing.T) {
78+
m := newTestModel()
79+
m.msgs = append(m.msgs,
80+
message{role: roleUser, content: "q1"},
81+
message{role: roleAsst, content: "a1", rendered: m.render("a1"), steps: []step{
82+
{name: "read", done: true, result: "ok"},
83+
}},
84+
message{role: roleUser, content: "q2"},
85+
message{role: roleAsst, content: "a2", rendered: m.render("a2")},
86+
)
87+
m.refresh()
88+
block3 := m.msgBlocks[3].block
89+
m.toggleStep(1, 0)
90+
if len(m.msgBlocks) > 1 && m.msgBlocks[1].valid {
91+
t.Error("toggle should invalidate message 1 block")
92+
}
93+
if len(m.msgBlocks) < 4 || !m.msgBlocks[3].valid || m.msgBlocks[3].block != block3 {
94+
t.Error("sibling message block should stay cached")
95+
}
96+
}

‎internal/tui/chrome.go‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,14 +320,36 @@ func (m *Model) sessionHome() string {
320320
if task == "" {
321321
task = "(untitled)"
322322
}
323-
b.WriteString(th.statsDim.Render(fmt.Sprintf("↩ %s · %d turns · %s",
324-
truncate(task, max(w-24, 12)), s.Turns, ago(s.UpdatedAt))) + "\n")
323+
b.WriteString(th.statsDim.Render(fmt.Sprintf("%d ↩ %s · %d turns · %s",
324+
i+1, truncate(task, max(w-24, 12)), s.Turns, ago(s.UpdatedAt))) + "\n")
325325
}
326326
b.WriteString("\n")
327-
b.WriteString(th.tipKey.Render("type a task") + " " + th.tipText.Render("⏎ sends · ^K everything · /verbosity dials detail") + "\n")
327+
b.WriteString(th.tipKey.Render("type a task") + " " + th.tipText.Render("1–3 resume · ⏎ sends · ^K everything · /verbosity dials detail") + "\n")
328328
return lipgloss.NewStyle().Width(w).PaddingLeft(2).Render(strings.TrimRight(b.String(), "\n"))
329329
}
330330

331+
// handleHomeResumeKey resumes a recent session from the cleared home card.
332+
func (m *Model) handleHomeResumeKey(key string) tea.Cmd {
333+
if len(m.msgs) > 0 || m.busy || m.panel != panelNone {
334+
return nil
335+
}
336+
var slot int
337+
switch key {
338+
case "1":
339+
slot = 0
340+
case "2":
341+
slot = 1
342+
case "3":
343+
slot = 2
344+
default:
345+
return nil
346+
}
347+
if slot >= len(m.homeSess) || slot >= 3 {
348+
return nil
349+
}
350+
return m.resumeSession(m.homeSess[slot].ID)
351+
}
352+
331353
func captureHome(m *Model) {
332354
if p := lastUserPrompt(m.msgs); p != "" {
333355
m.homePrompt = p

‎internal/tui/commands_e2e_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func callCmd(cmd tea.Cmd) tea.Msg {
5757

5858
func timeReArm(msg tea.Msg) bool {
5959
switch msg.(type) {
60-
case spinner.TickMsg, heartbeatMsg, runsTickMsg, noticeExpireMsg, renderFlushMsg:
60+
case spinner.TickMsg, heartbeatMsg, runsTickMsg, noticeExpireMsg, renderFlushMsg, tailClockFlushMsg:
6161
return true
6262
}
6363
return false

‎internal/tui/events.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
151151
// unfold it once so the diagnosis is on screen
152152
// without a hunt (the user can re-collapse it).
153153
steps[j].expanded = true
154-
m.convCount = -1
154+
clearStepBlockCache(&steps[j])
155+
m.invalidateMsgBlock(i)
155156
}
156157
if steps[j].subagent {
157158
steps[j].resultCard = parseAgentResult(ev.Data)
@@ -824,6 +825,7 @@ func markCancel(msg *message) {
824825
func (m *Model) finalize() {
825826
if i := m.cur(); i >= 0 {
826827
m.closeTurn(&m.msgs[i])
828+
m.invalidateMsgBlock(i)
827829
}
828830
m.curIdx = -1
829831
m.wakeArmed = false // the window closed with the turn

‎internal/tui/home_dashboard_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func TestHomeShowsRecentSessions(t *testing.T) {
2020
{ID: "s2", Task: "refactor the parser", Turns: 9, UpdatedAt: time.Now().Add(-2 * time.Hour)},
2121
}
2222
out := plain(m.sessionHome())
23-
for _, want := range []string{"fix the login bug", "refactor the parser", "4 turns", "^K"} {
23+
for _, want := range []string{"fix the login bug", "refactor the parser", "4 turns", "1 ↩", "1–3 resume"} {
2424
if !strings.Contains(out, want) {
2525
t.Errorf("session home missing %q:\n%s", want, out)
2626
}

‎internal/tui/input.go‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -522,15 +522,20 @@ func (m *Model) dismissChrome() (bool, tea.Cmd) {
522522
}
523523
if m.expandAll {
524524
m.expandAll = false
525-
m.convCount = -1
525+
m.invalidateAllMsgBlocks()
526+
for i := range m.msgs {
527+
for j := range m.msgs[i].steps {
528+
clearStepBlockCache(&m.msgs[i].steps[j])
529+
}
530+
}
526531
m.refresh()
527532
return true, nil
528533
}
529534
for i := len(m.msgs) - 1; i >= 0; i-- {
530535
for j := len(m.msgs[i].items) - 1; j >= 0; j-- {
531536
if m.msgs[i].items[j].thinking && m.msgs[i].items[j].open {
532537
m.msgs[i].items[j].open = false
533-
m.convCount = -1
538+
m.invalidateMsgBlock(i)
534539
m.refresh()
535540
return true, nil
536541
}
@@ -539,7 +544,8 @@ func (m *Model) dismissChrome() (bool, tea.Cmd) {
539544
for i := len(m.msgs) - 1; i >= 0; i-- {
540545
for j := len(m.msgs[i].steps) - 1; j >= 0; j-- {
541546
if m.msgs[i].steps[j].clearAgentFocus() {
542-
m.convCount = -1
547+
clearStepBlockCache(&m.msgs[i].steps[j])
548+
m.invalidateMsgBlock(i)
543549
m.refresh()
544550
return true, nil
545551
}
@@ -549,15 +555,16 @@ func (m *Model) dismissChrome() (bool, tea.Cmd) {
549555
for j := len(m.msgs[i].steps) - 1; j >= 0; j-- {
550556
if m.msgs[i].steps[j].expanded {
551557
m.msgs[i].steps[j].expanded = false
552-
m.convCount = -1
558+
clearStepBlockCache(&m.msgs[i].steps[j])
559+
m.invalidateMsgBlock(i)
553560
m.refresh()
554561
return true, nil
555562
}
556563
}
557564
}
558565
if n := len(m.msgs); n > 0 && isEphemeralCard(m.msgs[n-1]) {
559566
m.msgs = m.msgs[:n-1]
560-
m.convCount = -1
567+
m.resetMsgBlocks()
561568
m.refresh()
562569
return true, nil
563570
}

0 commit comments

Comments
 (0)