Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/tui/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func (m *Model) invalidateMsgBlock(i int) {
return
}
m.msgBlocks[i].valid = false
m.convCount = -1 // the joined prefix embeds this block — drop it too
}

// invalidateAllMsgBlocks drops every cached message block (resize, expand-all,
Expand Down
32 changes: 32 additions & 0 deletions internal/tui/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,35 @@ func TestMsgBlockPartialInvalidate(t *testing.T) {
t.Error("sibling message block should stay cached")
}
}

// TestResizeSkipsGlamourRebuildForSameWidth pins the resize debounce: the
// glamour renderer (and the full-transcript re-render behind it) is rebuilt
// only when the wrap width changes; height-only and no-op resizes reuse it.
func TestResizeSkipsGlamourRebuildForSameWidth(t *testing.T) {
m := newTestModel() // resize(100,30) in the constructor: glam built once
if m.glam == nil {
t.Fatal("precondition: glamour renderer not built")
}
glam := m.glam
rendered := m.render("# hello")
m.resize(100, 24) // height-only
if m.glam != glam {
t.Error("height-only resize rebuilt the glamour renderer")
}
m.resize(100, 30) // no-op
if m.glam != glam {
t.Error("no-op resize rebuilt the glamour renderer")
}
if m.render("# hello") != rendered {
t.Error("same-width resize changed rendered output")
}
m.resize(120, 30) // width change rebuilds
if m.glam == glam {
t.Error("width change should rebuild the glamour renderer")
}
m.glamWrap = -1 // theme switch forces a rebuild even at unchanged width
m.resize(120, 30)
if m.glam == glam {
t.Error("forced rebuild (glamWrap -1) did not replace the renderer")
}
}
26 changes: 16 additions & 10 deletions internal/tui/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,25 @@ func (m *Model) paintCanvas(body string) string {
return body
}
frame := lipgloss.NewStyle().Width(m.width).Height(m.height).Render(body)
bg := surfaceSGR(m.th.canvas)
if bg == "" {
return frame
if !m.canvasSGRValid {
bg := surfaceSGR(m.th.canvas)
if bg == "" {
return frame
}
// A foreground-only probe gives the default text color in the active profile.
probe := lipgloss.NewStyle().Foreground(m.th.canvas.GetForeground()).Render(" ")
fg := ""
if i := strings.IndexByte(probe, ' '); i >= 0 {
fg = probe[:i]
}
m.canvasFG, m.canvasBG, m.canvasSGRValid = fg, bg, true
}
// A foreground-only probe gives the default text color in the active profile.
probe := lipgloss.NewStyle().Foreground(m.th.canvas.GetForeground()).Render(" ")
fg := ""
if i := strings.IndexByte(probe, ' '); i >= 0 {
fg = probe[:i]
if m.canvasBG == "" {
return frame
}
base := fg + bg
base := m.canvasFG + m.canvasBG
frame = strings.NewReplacer("\x1b[0m", "\x1b[0m"+base,
"\x1b[m", "\x1b[m"+base, "\x1b[49m", bg, "\x1b[39m", fg).Replace(frame)
"\x1b[m", "\x1b[m"+base, "\x1b[49m", m.canvasBG, "\x1b[39m", m.canvasFG).Replace(frame)
// Reset-at-line-start insurance: a reset at the very end of a row clears
// the background for the whole following row on some terminals, and rows
// that never carried an escape (blank transcript rows, plain text lines,
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ func (m *Model) switchTheme(name string) tea.Cmd {
m.ta.FocusedStyle.Placeholder = m.th.inputPlaceholder
m.ta.BlurredStyle.Placeholder = m.th.inputPlaceholder
m.logoCache = "" // the banner gradient is palette-dependent
m.canvasSGRValid = false
m.glamWrap = -1 // palette changed — force a glamour rebuild on resize
// Raw cards are point-in-time snapshots and never go through glamour
// (resize skips them) — the /help card must still follow the palette, so
// regenerate it in place, preserving transcript position.
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/drawer.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ func (m *Model) jumpToAgentStep() tea.Cmd {
m.msgs[i].steps[j].setAgentFocus(a.idx)
}
m.msgs[i].steps[j].expanded = true
clearStepBlockCache(&m.msgs[i].steps[j])
m.invalidateMsgBlock(i) // the cached prefix embeds this block
m.panel = panelNone
m.relayout()
m.scrollToMessage(i)
Expand Down
23 changes: 23 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,10 +399,15 @@ type Model struct {
quitting bool

gradRule string // cached full-width gradient rule
glamWrap int // wrap width m.glam was built for; -1 forces rebuild (theme switch)
gradRuleW int
logoCache string // cached gradient logo (width-independent)

canvasFG, canvasBG string // cached paintCanvas probe colors for the active theme
canvasSGRValid bool // canvasFG/BG populated; cleared on theme switch

convPrefix string // joined finalized prefix (assembled from msgBlocks)
convPrefixLines int // lineCount(convPrefix), cached so refresh skips re-measuring
convPrefixRefs []stepRef // step header line index for the cached prefix
convPrefixTurn []stepRef // turn-head line index for the cached prefix (stepIdx -1)
convPrefixMsgs []stepRef // per-message first-line index for the cached prefix
Expand Down Expand Up @@ -1376,6 +1381,24 @@ func (m *Model) resize(w, h int) tea.Cmd {
if wrap < 20 {
wrap = 20
}
// The transcript's glamour renders depend only on the wrap width —
// rebuilding the renderer and re-rendering every message on height-only
// or no-op resizes (window-drag bursts) is wasted full-transcript work.
// glamWrap == -1 forces a rebuild (theme switch swaps the palette).
if wrap == m.glamWrap {
// Same width: no renderer rebuild, but a finalized message whose
// render was never produced (finalized after the last resize)
// still needs its glamour pass once.
for i := range m.msgs {
if m.msgs[i].role != roleAsst || m.msgs[i].raw || m.msgs[i].streaming || m.msgs[i].rendered != "" {
continue
}
m.msgs[i].rendered = m.render(m.msgs[i].content)
}
m.refresh()
return nil
}
m.glamWrap = wrap
if r, err := glamour.NewTermRenderer(
glamour.WithStyles(answerGlamourStyle()),
glamour.WithWordWrap(wrap),
Expand Down
123 changes: 123 additions & 0 deletions internal/tui/perf_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package tui

import (
"fmt"
"strings"
"testing"
"time"

"github.com/BackendStack21/bodek/internal/client"
)

// benchTranscript builds a transcript of n finalized user/assistant pairs so
// benchmarks exercise a realistic long session, not a two-message chat.
func benchTranscript(n int) []message {
msgs := make([]message, 0, n*2)
for i := 0; i < n; i++ {
body := fmt.Sprintf("Answer %d: %s", i, strings.Repeat("lorem ipsum dolor sit amet ", 8))
msgs = append(msgs,
message{role: roleUser, content: fmt.Sprintf("question %d", i)},
message{role: roleAsst, content: body},
)
}
return msgs
}

// BenchmarkConversationLongSession measures the transcript rebuild per
// refresh: prefix join, block cache reads, tail rendering. This is the cost
// paid ~12 Hz while streaming and on every UI transition.
func BenchmarkConversationLongSession(b *testing.B) {
for _, n := range []int{25, 100, 400} {
b.Run(fmt.Sprintf("msgs=%d", n*2), func(b *testing.B) {
m := newTestModel()
m.ready = true
m.msgs = benchTranscript(n)
b.ResetTimer()
for i := 0; i < b.N; i++ {
m.conversation()
}
})
}
}

// BenchmarkRefreshStreaming measures the full refresh path (conversation +
// viewport SetContent) with a streaming tail message, as fired by the 80 ms
// render flush while a turn streams.
func BenchmarkRefreshStreaming(b *testing.B) {
for _, n := range []int{25, 100} {
b.Run(fmt.Sprintf("msgs=%d", n*2+1), func(b *testing.B) {
m := newTestModel()
m.ready = true
m.msgs = benchTranscript(n)
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true})
m.curIdx = len(m.msgs) - 1
m.busy = true
m.runStart = time.Now()
m.handleEvent(client.Event{Type: "token", Content: strings.Repeat("streaming answer text grows here; ", 32)})
b.ResetTimer()
for i := 0; i < b.N; i++ {
m.refresh()
}
})
}
}

// BenchmarkIngestTokenBatch measures handleEvent ingestion of reply-token
// deltas into the streaming turn — the per-frame cost before any rendering.
func BenchmarkIngestTokenBatch(b *testing.B) {
m := newTestModel()
m.ready = true
m.msgs = append(m.msgs,
message{role: roleUser, content: "go"},
message{role: roleAsst, streaming: true},
)
m.curIdx = 1
m.busy = true
m.runStart = time.Now()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m.handleEvent(client.Event{Type: "token", Content: "answer tokens keep arriving in small fragments; "})
}
}

// BenchmarkAppendReply isolates the delta-accumulation cost on a growing
// reply segment (string += today).
func BenchmarkAppendReply(b *testing.B) {
for _, chunks := range []int{100, 1000} {
b.Run(fmt.Sprintf("deltas=%d", chunks), func(b *testing.B) {
for i := 0; i < b.N; i++ {
msg := &message{}
appendReply(msg, "seed text\n\n")
for j := 0; j < chunks; j++ {
appendReply(msg, "fragment of streamed answer text; ")
}
}
})
}
}

// BenchmarkPaintCanvas measures the full-frame canvas paint (padding +
// SGR splice) used by light/canvas themes on every View.
func BenchmarkPaintCanvas(b *testing.B) {
m := newTestModel()
m.resize(120, 40)
// Force a canvas-bearing theme so paintCanvas does its real work.
m.th = themeFrom(emberLight)
body := strings.Repeat(strings.Repeat("x", 118)+"\n", 38)
b.ResetTimer()
for i := 0; i < b.N; i++ {
m.paintCanvas(body)
}
}

// BenchmarkClampLines measures the line clamp + width re-measure applied to
// freshly rendered blocks and the streaming tail on every flush.
func BenchmarkClampLines(b *testing.B) {
m := newTestModel()
m.resize(100, 30)
s := strings.Repeat(strings.Repeat("wrapped line of transcript output ", 3)+"\n", 60)
b.ResetTimer()
for i := 0; i < b.N; i++ {
m.clampLines(s)
}
}
8 changes: 5 additions & 3 deletions internal/tui/steps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,12 +374,14 @@ func TestToggleStep(t *testing.T) {
t.Error("toggleStep did not expand the step")
}
m.ensureMsgBlocks()
if m.convCount == -1 {
t.Error("toggleStep should not invalidate the whole prefix")
}
if len(m.msgBlocks) == 0 || m.msgBlocks[0].valid {
t.Error("toggleStep did not invalidate the message block")
}
// The joined prefix embeds message 0's block, so it is stale — but
// sibling blocks keep their caches (no wholesale invalidation).
if m.convCount != -1 {
t.Error("toggleStep must drop the stale joined prefix (convCount)")
}
out := plain(m.conversation())
if !strings.Contains(out, "▼") || !strings.Contains(out, "FAIL") {
t.Errorf("expanded step not rendered:\n%s", out)
Expand Down
49 changes: 32 additions & 17 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,24 +554,39 @@ func (m *Model) conversation() string {
}
lineOffset := 0
blocks := make([]string, 0, tail+2)
for i := 0; i < tail; i++ {
collectTurn(i, lineOffset)
c, r := m.msgBlockAt(i, lineOffset)
blocks = append(blocks, c)
absorbRefs(r)
lineOffset += lineCount(c) + 1
}
m.convPrefix = strings.Join(blocks, turnSep)
m.convPrefixRefs = refs
m.convPrefixTurn = turns
m.convPrefixMsgs = msgsIdx
m.convCount = tail
if m.convPrefix != "" {
lineOffset = lineCount(m.convPrefix) + 1
}
blocks = blocks[:0]
if m.convPrefix != "" {
if m.convCount == tail {
// Prefix unchanged since the last build (no finalize, invalidation, or
// resize): reuse the joined string and its cached line count instead
// of re-reading every block and re-joining — the hot path for the
// ~12 Hz render flush while a turn streams.
blocks = append(blocks, m.convPrefix)
refs = append(refs, m.convPrefixRefs...)
turns = append(turns, m.convPrefixTurn...)
msgsIdx = append(msgsIdx, m.convPrefixMsgs...)
if m.convPrefix != "" {
lineOffset = m.convPrefixLines + 1
}
} else {
for i := 0; i < tail; i++ {
collectTurn(i, lineOffset)
c, r := m.msgBlockAt(i, lineOffset)
blocks = append(blocks, c)
absorbRefs(r)
lineOffset += lineCount(c) + 1
}
m.convPrefix = strings.Join(blocks, turnSep)
m.convPrefixLines = lineCount(m.convPrefix)
m.convPrefixRefs = refs
m.convPrefixTurn = turns
m.convPrefixMsgs = msgsIdx
m.convCount = tail
if m.convPrefix != "" {
lineOffset = m.convPrefixLines + 1
}
blocks = blocks[:0]
if m.convPrefix != "" {
blocks = append(blocks, m.convPrefix)
}
}
for i := tail; i < len(m.msgs); i++ {
if emptyStreamingTurn(m.msgs[i]) {
Expand Down
Loading