Skip to content

Commit b544696

Browse files
perf(tui): cache pre-rendered log lines to eliminate per-frame rebuilds
The log viewer was re-rendering ALL entries (including Chroma syntax highlighting) on every single frame — every keypress, scroll, and incoming event. This caused visible lag when switching to the log view and sluggish scrolling with many entries. Changes: - Pre-render and cache lines on AddEvent instead of on every Render - Pre-compute syntax highlighting once per entry (width-independent) - Render only collects visible lines from cached entries (O(viewport)) - totalLines() is now O(1) via a running counter - SetSize rebuilds cache on width change but skips re-highlighting - Fix SetSize width mismatch between Update handlers and renderLogView - Throttle PRD reloads to meaningful events only (not every tool call) Closes #15
1 parent def1da5 commit b544696

3 files changed

Lines changed: 691 additions & 75 deletions

File tree

internal/tui/app.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
369369
case tea.WindowSizeMsg:
370370
a.width = msg.Width
371371
a.height = msg.Height
372-
// Update log viewer size
373-
a.logViewer.SetSize(a.width, a.height-a.effectiveHeaderHeight()-footerHeight-2)
372+
// Log viewer size is set authoritatively in renderLogView (with correct -4 width).
373+
// Only update height here for scroll calculations; width will match on next render.
374+
a.logViewer.SetSize(a.width-4, a.height-headerHeight-footerHeight-2)
374375
return a, nil
375376

376377
case LoopEventMsg:
@@ -512,7 +513,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
512513
case "t":
513514
if a.viewMode == ViewDashboard || a.viewMode == ViewDiff {
514515
a.viewMode = ViewLog
515-
a.logViewer.SetSize(a.width, a.height-a.effectiveHeaderHeight()-footerHeight-2)
516+
// SetSize is handled by renderLogView with correct dimensions
516517
} else {
517518
a.viewMode = ViewDashboard
518519
}
@@ -867,10 +868,13 @@ func (a App) handleLoopEvent(prdName string, event loop.Event) (tea.Model, tea.C
867868
}
868869
}
869870

870-
// Reload PRD if this is the current one to reflect any changes made by Claude
871+
// Reload PRD from disk only on meaningful state changes (not every event)
871872
if isCurrentPRD {
872-
if p, err := prd.LoadPRD(a.prdPath); err == nil {
873-
a.prd = p
873+
switch event.Type {
874+
case loop.EventStoryStarted, loop.EventComplete, loop.EventError, loop.EventMaxIterationsReached:
875+
if p, err := prd.LoadPRD(a.prdPath); err == nil {
876+
a.prd = p
877+
}
874878
}
875879
}
876880

internal/tui/log.go

Lines changed: 86 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ type LogEntry struct {
2222
ToolInput map[string]interface{}
2323
StoryID string
2424
FilePath string // For Read tool results, stores the file path for syntax highlighting
25+
26+
highlightedCode string // Pre-computed syntax highlighted code (computed once on add)
27+
cachedLines []string // Pre-rendered output lines (invalidated on width change)
2528
}
2629

2730
// LogViewer manages the log viewport state.
@@ -32,6 +35,7 @@ type LogViewer struct {
3235
width int // Viewport width
3336
autoScroll bool // Auto-scroll to bottom when new content arrives
3437
lastReadFilePath string // Track the last Read tool's file path for syntax highlighting
38+
totalLineCount int // Running total of all rendered lines (O(1) lookup)
3539
}
3640

3741
// NewLogViewer creates a new log viewer.
@@ -60,16 +64,24 @@ func (l *LogViewer) AddEvent(event loop.Event) {
6064
}
6165
}
6266

63-
// For tool results, attach the file path from the preceding Read tool
67+
// For tool results, attach the file path and pre-compute syntax highlighting
6468
if event.Type == loop.EventToolResult && l.lastReadFilePath != "" {
6569
entry.FilePath = l.lastReadFilePath
6670
l.lastReadFilePath = "" // Clear after consuming
71+
if entry.Text != "" {
72+
entry.highlightedCode = l.highlightCode(entry.Text, entry.FilePath)
73+
}
6774
}
6875

6976
// Filter out events we don't want to display
7077
switch event.Type {
7178
case loop.EventAssistantText, loop.EventToolStart, loop.EventToolResult,
7279
loop.EventStoryStarted, loop.EventComplete, loop.EventError, loop.EventRetrying:
80+
// Pre-render and cache lines
81+
if l.width > 0 {
82+
entry.cachedLines = l.renderEntry(entry)
83+
l.totalLineCount += len(entry.cachedLines)
84+
}
7385
l.entries = append(l.entries, entry)
7486
default:
7587
// Skip iteration start, unknown events, etc.
@@ -82,10 +94,26 @@ func (l *LogViewer) AddEvent(event loop.Event) {
8294
}
8395
}
8496

85-
// SetSize sets the viewport dimensions.
97+
// SetSize sets the viewport dimensions. Rebuilds the line cache if width changed.
8698
func (l *LogViewer) SetSize(width, height int) {
99+
widthChanged := l.width != width
87100
l.width = width
88101
l.height = height
102+
103+
if widthChanged && width > 0 {
104+
l.rebuildCache()
105+
}
106+
}
107+
108+
// rebuildCache re-renders all entries using the current width.
109+
// This is called when the terminal is resized. Syntax highlighting is NOT
110+
// recomputed since it's width-independent and stored in highlightedCode.
111+
func (l *LogViewer) rebuildCache() {
112+
l.totalLineCount = 0
113+
for i := range l.entries {
114+
l.entries[i].cachedLines = l.renderEntry(l.entries[i])
115+
l.totalLineCount += len(l.entries[i].cachedLines)
116+
}
89117
}
90118

91119
// ScrollUp scrolls up by one line.
@@ -144,57 +172,29 @@ func (l *LogViewer) ScrollToTop() {
144172
l.autoScroll = false
145173
}
146174

147-
// ScrollToBottom scrolls to the bottom.
148-
func (l *LogViewer) scrollToBottom() {
149-
l.scrollPos = l.maxScrollPos()
150-
l.autoScroll = true
151-
}
152-
153175
// ScrollToBottom (exported) scrolls to the bottom.
154176
func (l *LogViewer) ScrollToBottom() {
155177
l.scrollToBottom()
156178
}
157179

180+
// scrollToBottom scrolls to the bottom.
181+
func (l *LogViewer) scrollToBottom() {
182+
l.scrollPos = l.maxScrollPos()
183+
l.autoScroll = true
184+
}
185+
158186
// maxScrollPos returns the maximum scroll position.
159187
func (l *LogViewer) maxScrollPos() int {
160-
totalLines := l.totalLines()
161-
maxPos := totalLines - l.height
188+
maxPos := l.totalLineCount - l.height
162189
if maxPos < 0 {
163190
return 0
164191
}
165192
return maxPos
166193
}
167194

168-
// totalLines calculates the total number of rendered lines.
195+
// totalLines returns the total number of rendered lines (O(1)).
169196
func (l *LogViewer) totalLines() int {
170-
if l.width <= 0 {
171-
return len(l.entries)
172-
}
173-
174-
total := 0
175-
for _, entry := range l.entries {
176-
total += l.entryHeight(entry)
177-
}
178-
return total
179-
}
180-
181-
// entryHeight calculates how many lines an entry takes.
182-
func (l *LogViewer) entryHeight(entry LogEntry) int {
183-
switch entry.Type {
184-
case loop.EventToolStart:
185-
// Tool display is now a single line
186-
return 1
187-
case loop.EventToolResult:
188-
// Tool result is typically compact
189-
return 1
190-
default:
191-
// Text entries: count wrapped lines
192-
if entry.Text == "" {
193-
return 1
194-
}
195-
wrapped := wrapText(entry.Text, l.width-4)
196-
return strings.Count(wrapped, "\n") + 1
197-
}
197+
return l.totalLineCount
198198
}
199199

200200
// getToolIcon returns an emoji icon for a tool name.
@@ -276,9 +276,10 @@ func (l *LogViewer) Clear() {
276276
l.entries = make([]LogEntry, 0)
277277
l.scrollPos = 0
278278
l.autoScroll = true
279+
l.totalLineCount = 0
279280
}
280281

281-
// Render renders the log viewer content.
282+
// Render renders only the visible portion of the log viewer.
282283
func (l *LogViewer) Render() string {
283284
if len(l.entries) == 0 {
284285
emptyStyle := lipgloss.NewStyle().
@@ -287,31 +288,50 @@ func (l *LogViewer) Render() string {
287288
return emptyStyle.Render("No log entries yet. Start the loop to see Claude's activity.")
288289
}
289290

290-
// Build all lines
291-
var allLines []string
292-
for _, entry := range l.entries {
293-
lines := l.renderEntry(entry)
294-
allLines = append(allLines, lines...)
295-
}
296-
297-
// Apply scrolling
291+
// Calculate visible range
298292
startLine := l.scrollPos
299293
if startLine < 0 {
300294
startLine = 0
301295
}
302-
if startLine >= len(allLines) {
303-
startLine = len(allLines) - 1
296+
if startLine >= l.totalLineCount {
297+
startLine = l.totalLineCount - 1
304298
if startLine < 0 {
305299
startLine = 0
306300
}
307301
}
308302

309303
endLine := startLine + l.height
310-
if endLine > len(allLines) {
311-
endLine = len(allLines)
304+
if endLine > l.totalLineCount {
305+
endLine = l.totalLineCount
312306
}
313307

314-
visibleLines := allLines[startLine:endLine]
308+
// Collect only visible lines by scanning cached entries
309+
currentLine := 0
310+
var visibleLines []string
311+
312+
for i := range l.entries {
313+
lines := l.entries[i].cachedLines
314+
entryEnd := currentLine + len(lines)
315+
316+
// Skip entries entirely before the viewport
317+
if entryEnd <= startLine {
318+
currentLine = entryEnd
319+
continue
320+
}
321+
// Stop once we're past the viewport
322+
if currentLine >= endLine {
323+
break
324+
}
325+
326+
// This entry has some visible lines
327+
for j, line := range lines {
328+
lineNum := currentLine + j
329+
if lineNum >= startLine && lineNum < endLine {
330+
visibleLines = append(visibleLines, line)
331+
}
332+
}
333+
currentLine = entryEnd
334+
}
315335

316336
// Add cursor indicator at bottom if streaming
317337
content := strings.Join(visibleLines, "\n")
@@ -404,24 +424,21 @@ func (l *LogViewer) renderToolResult(entry LogEntry) []string {
404424
return []string{resultStyle.Render(checkStyle.Render(" ↳ ") + "(no output)")}
405425
}
406426

407-
// If this is a Read result with a file path, apply syntax highlighting
408-
if entry.FilePath != "" {
409-
highlighted := l.highlightCode(text, entry.FilePath)
410-
if highlighted != "" {
411-
lines := strings.Split(highlighted, "\n")
412-
var result []string
413-
result = append(result, checkStyle.Render(" ↳ ")) // Result indicator
414-
// Limit to 20 lines to keep the log view manageable
415-
maxLines := 20
416-
for i, line := range lines {
417-
if i >= maxLines {
418-
result = append(result, resultStyle.Render(fmt.Sprintf(" ... (%d more lines)", len(lines)-maxLines)))
419-
break
420-
}
421-
result = append(result, " "+line)
427+
// Use pre-computed syntax highlighting if available
428+
if entry.highlightedCode != "" {
429+
lines := strings.Split(entry.highlightedCode, "\n")
430+
var result []string
431+
result = append(result, checkStyle.Render(" ↳ ")) // Result indicator
432+
// Limit to 20 lines to keep the log view manageable
433+
maxLines := 20
434+
for i, line := range lines {
435+
if i >= maxLines {
436+
result = append(result, resultStyle.Render(fmt.Sprintf(" ... (%d more lines)", len(lines)-maxLines)))
437+
break
422438
}
423-
return result
439+
result = append(result, " "+line)
424440
}
441+
return result
425442
}
426443

427444
// Fallback: show a compact single-line result

0 commit comments

Comments
 (0)