From 8ae204f12fb312b64065b651ad43d9dea50f6a2e Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 27 Mar 2026 12:13:04 +0300 Subject: [PATCH] fix: preserve full content in scrollback by separating render cache from viewport The StreamComponent was truncating content to fit the viewport height before caching it in renderCache. This caused GetRenderedContent() to return truncated content when flushing to scrollback. Changes: - render() now caches FULL content without height clamping - New viewContent() helper applies height clamping only for display - View() calls both: render() for full content, viewContent() for visible slice This follows the Pi TUI pattern: full buffer in memory, viewport slicing only at display time. Long assistant messages are now fully preserved in scrollback. --- internal/ui/stream.go | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 5b94447a..79a0b401 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -282,7 +282,8 @@ func (s *StreamComponent) GetRenderedContent() string { text := s.streamContent.String() if text != "" { - sections = append(sections, s.renderStreamingText(text)) + rendered := s.renderStreamingText(text) + sections = append(sections, rendered) } if len(sections) == 0 { @@ -415,7 +416,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View implements tea.Model. Renders the current stream region content. func (s *StreamComponent) View() tea.View { - return tea.NewView(s.render()) + fullContent := s.render() + visibleContent := s.viewContent(fullContent) + return tea.NewView(visibleContent) } // -------------------------------------------------------------------------- @@ -458,21 +461,27 @@ func (s *StreamComponent) render() string { content := strings.Join(sections, "\n") - // Clamp to height if constrained: keep the last h lines so the most - // recent output is always visible. - if s.height > 0 && content != "" { - lines := strings.Split(content, "\n") - if len(lines) > s.height { - lines = lines[len(lines)-s.height:] - content = strings.Join(lines, "\n") - } - } - + // Cache FULL content without height clamping. + // Height clamping is applied in View() for display only. s.renderCache = content s.renderDirty = false return content } +// viewContent returns the visible portion of content based on height constraint. +// This is called by View() to get the slice that fits in the terminal. +func (s *StreamComponent) viewContent(fullContent string) string { + if s.height > 0 && fullContent != "" { + lines := strings.Split(fullContent, "\n") + if len(lines) > s.height { + // Keep only the last h lines so the most recent output is visible. + lines = lines[len(lines)-s.height:] + return strings.Join(lines, "\n") + } + } + return fullContent +} + // renderReasoningBlock renders the reasoning/thinking content in a surface-tinted // box. When collapsed, shows the last 10 lines with a truncation hint. When // expanded, shows all lines. Includes a "Thought for Xs" duration footer.