@@ -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.
8698func (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.
154176func (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.
159187func (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)) .
169196func (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.
282283func (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