diff --git a/internal/ui/children_test.go b/internal/ui/children_test.go index 1abd8d33..46b36133 100644 --- a/internal/ui/children_test.go +++ b/internal/ui/children_test.go @@ -699,3 +699,170 @@ func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) { t.Fatalf("expected streamContent='new' after current flush, got %q", got) } } + +// TestStreamComponent_ConsumeOverflow_NoHeight verifies that when height is +// unconstrained (0), ConsumeOverflow always returns "". +func TestStreamComponent_ConsumeOverflow_NoHeight(t *testing.T) { + c := newTestStream() + // Commit some content directly. + c.streamContent.WriteString("line1\nline2\nline3") + c.phase = streamPhaseActive + c.renderDirty = true + + if got := c.ConsumeOverflow(); got != "" { + t.Fatalf("expected empty with height=0, got %q", got) + } +} + +// TestStreamComponent_ConsumeOverflow_NoOverflow verifies that when content fits +// within the allocated height, ConsumeOverflow returns "". +func TestStreamComponent_ConsumeOverflow_NoOverflow(t *testing.T) { + c := newTestStream() + c.streamContent.WriteString("line1\nline2") + c.phase = streamPhaseActive + c.renderDirty = true + c.height = 20 // plenty of room + + if got := c.ConsumeOverflow(); got != "" { + t.Fatalf("expected empty when content fits, got %q", got) + } +} + +// TestStreamComponent_ConsumeOverflow_EmitsTopLines verifies that when the +// rendered content has more lines than the allocated height, ConsumeOverflow +// returns the top overflow lines and advances the internal pointer. +func TestStreamComponent_ConsumeOverflow_EmitsTopLines(t *testing.T) { + c := newTestStream() + c.height = 2 + + // Build raw content that when "rendered" (plain text for this test) + // is 5 lines — we bypass the markdown renderer by writing directly to + // streamContent and using a nil renderer. + c.renderer = nil + c.streamContent.WriteString("a\nb\nc\nd\ne") + c.phase = streamPhaseActive + c.renderDirty = true + + // First call: should return lines a, b, c (5 lines - 2 visible = 3 overflow). + overflow1 := c.ConsumeOverflow() + if overflow1 == "" { + t.Fatal("expected overflow, got empty") + } + overflowLines := strings.Split(overflow1, "\n") + if len(overflowLines) != 3 { + t.Fatalf("expected 3 overflow lines, got %d: %q", len(overflowLines), overflow1) + } + if overflowLines[0] != "a" || overflowLines[1] != "b" || overflowLines[2] != "c" { + t.Fatalf("unexpected overflow lines: %v", overflowLines) + } + + // Second call without new content should return "" (pointer already advanced). + overflow2 := c.ConsumeOverflow() + if overflow2 != "" { + t.Fatalf("expected empty on second call, got %q", overflow2) + } +} + +// TestStreamComponent_ConsumeOverflow_IncrementalFlush verifies that as new +// content arrives, ConsumeOverflow incrementally returns only newly overflowed +// lines on each call. +func TestStreamComponent_ConsumeOverflow_IncrementalFlush(t *testing.T) { + c := newTestStream() + c.height = 2 + c.renderer = nil + c.phase = streamPhaseActive + + // Start with 3 lines — 1 overflows. + c.streamContent.WriteString("a\nb\nc") + c.renderDirty = true + + overflow1 := c.ConsumeOverflow() + if overflow1 != "a" { + t.Fatalf("expected 'a', got %q", overflow1) + } + + // Add 2 more lines — 2 additional overflows. + c.streamContent.WriteString("\nd\ne") + c.renderDirty = true + + overflow2 := c.ConsumeOverflow() + want := "b\nc" + if overflow2 != want { + t.Fatalf("expected %q, got %q", want, overflow2) + } +} + +// TestStreamComponent_ConsumeOverflow_ResetClearsPointer verifies that Reset() +// resets the scrollback pointer so the next response starts fresh. +func TestStreamComponent_ConsumeOverflow_ResetClearsPointer(t *testing.T) { + c := newTestStream() + c.height = 1 + c.renderer = nil + c.phase = streamPhaseActive + + c.streamContent.WriteString("a\nb") + c.renderDirty = true + overflow := c.ConsumeOverflow() + if overflow != "a" { + t.Fatalf("expected 'a', got %q", overflow) + } + + c.Reset() + if c.scrollbackFlushedLines != 0 { + t.Fatalf("expected scrollbackFlushedLines=0 after Reset, got %d", c.scrollbackFlushedLines) + } +} + +// TestStreamComponent_GetRenderedContent_SkipsFlushedLines verifies that +// GetRenderedContent skips lines already emitted via ConsumeOverflow so the +// caller doesn't re-print content already in the terminal scrollback. +func TestStreamComponent_GetRenderedContent_SkipsFlushedLines(t *testing.T) { + c := newTestStream() + c.height = 2 + c.renderer = nil + c.phase = streamPhaseActive + + // 5 lines → 3 overflow, 2 visible. + c.streamContent.WriteString("a\nb\nc\nd\ne") + c.renderDirty = true + + // Consume the overflow: lines a, b, c. + overflow := c.ConsumeOverflow() + if overflow != "a\nb\nc" { + t.Fatalf("expected 'a\\nb\\nc', got %q", overflow) + } + if c.scrollbackFlushedLines != 3 { + t.Fatalf("expected flushedLines=3, got %d", c.scrollbackFlushedLines) + } + + // GetRenderedContent should only return the non-flushed portion: d, e. + got := c.GetRenderedContent() + if got != "d\ne" { + t.Fatalf("expected 'd\\ne', got %q", got) + } +} + +// TestStreamComponent_GetRenderedContent_AllFlushed verifies that when all +// lines have been pushed via ConsumeOverflow, GetRenderedContent returns "". +func TestStreamComponent_GetRenderedContent_AllFlushed(t *testing.T) { + c := newTestStream() + c.height = 1 + c.renderer = nil + c.phase = streamPhaseActive + + // 2 lines → height=1, so 1 overflow. + c.streamContent.WriteString("a\nb") + c.renderDirty = true + + // Consume overflow (line a), leaving 1 visible line (b). + _ = c.ConsumeOverflow() + + // Now bump height so everything overflows — simulate a resize that made + // the viewable area 0, forcing all content to be "flushed". + c.scrollbackFlushedLines = 2 // pretend both lines were flushed + + got := c.GetRenderedContent() + if got != "" { + t.Fatalf("expected empty when all lines flushed, got %q", got) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 20b3c7e5..cae25a54 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -637,6 +637,10 @@ type streamComponentIface interface { // GetRenderedContent returns the rendered assistant message from accumulated // streaming text, or empty string if nothing has been accumulated. GetRenderedContent() string + // ConsumeOverflow returns lines from the top of the rendered content that + // have overflowed the allocated height and haven't been pushed to the + // terminal scrollback yet. Returns "" when no new overflow exists. + ConsumeOverflow() string // SpinnerView returns the rendered spinner line (animation + optional label). // Returns "" when the spinner is not active. The parent renders this in the // status bar so the spinner never changes the view height. @@ -1692,6 +1696,23 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Flush any stream overflow lines that have grown past the allocated + // height into the terminal's real scrollback buffer. This ensures the + // diagram's invariant: streaming text starts at the top of the viewable + // terminal and overflows upward into the scrollback buffer rather than + // silently discarding the older lines. + // + // IMPORTANT: overflow is emitted directly via tea.Println rather than + // via appendScrollback. Using appendScrollback would cause drainScrollback + // to see a non-empty scrollbackBuf and trigger its auto-flush, which calls + // GetRenderedContent() + Reset() while the stream is still active — + // causing duplication and premature resets. + if m.stream != nil { + if overflow := m.stream.ConsumeOverflow(); overflow != "" { + cmds = append(cmds, tea.Println(overflow)) + } + } + cmds = append(cmds, m.drainScrollback()) return m, tea.Batch(cmds...) } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 5706819c..69e81c4e 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -101,6 +101,7 @@ func (s *stubStreamComponent) View() tea.View { return tea.NewView(" func (s *stubStreamComponent) Reset() { s.resetCalled++; s.renderedContent = "" } func (s *stubStreamComponent) SetHeight(h int) { s.height = h } func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent } +func (s *stubStreamComponent) ConsumeOverflow() string { return "" } func (s *stubStreamComponent) SpinnerView() string { return "" } func (s *stubStreamComponent) SetThinkingVisible(bool) {} func (s *stubStreamComponent) HasReasoning() bool { return false } diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 25072f31..1a118413 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -205,6 +205,14 @@ type StreamComponent struct { // the cache. renderDirty bool + // scrollbackFlushedLines is the number of lines from the top of the + // rendered content that have already been emitted to the terminal + // scrollback buffer. On each flush, lines that overflow the allocated + // height and haven't been pushed yet are emitted via tea.Println so + // they appear in the terminal's real scrollback (scrollable with the + // terminal's own scroll mechanism). + scrollbackFlushedLines int + // thinkingVisible controls whether reasoning blocks are expanded or collapsed. thinkingVisible bool @@ -295,6 +303,42 @@ func (s *StreamComponent) Reset() { s.timestamp = time.Time{} s.reasoningStartTime = time.Time{} s.reasoningDuration = 0 + s.scrollbackFlushedLines = 0 +} + +// ConsumeOverflow returns any lines from the rendered stream content that have +// overflowed the allocated height and have not yet been pushed to the terminal +// scrollback buffer. It advances the internal flushed-line pointer so +// subsequent calls only return newly overflowed lines. +// +// Returns "" when there is no overflow or height is unconstrained (0). +// The caller should emit the returned string via tea.Println so the content +// appears in the terminal's real scrollback (not just discarded). +func (s *StreamComponent) ConsumeOverflow() string { + if s.height <= 0 { + return "" + } + content := s.render() + if content == "" { + return "" + } + lines := strings.Split(content, "\n") + totalLines := len(lines) + // Number of lines that overflow the viewable height. + overflowLines := totalLines - s.height + if overflowLines <= 0 { + return "" + } + // How many overflow lines are new (not yet flushed to scrollback). + newOverflow := overflowLines - s.scrollbackFlushedLines + if newOverflow <= 0 { + return "" + } + // The new overflow is lines [s.scrollbackFlushedLines .. overflowLines). + start := s.scrollbackFlushedLines + end := overflowLines + s.scrollbackFlushedLines = overflowLines + return strings.Join(lines[start:end], "\n") } // GetRenderedContent returns the rendered assistant message from the accumulated @@ -303,6 +347,10 @@ func (s *StreamComponent) Reset() { // // This commits any pending chunks first so the output includes all received // content, not just what has been flushed by the tick. +// +// Lines already pushed to the terminal scrollback buffer via ConsumeOverflow +// are skipped so that callers do not re-emit content that is already visible +// in the terminal's real scrollback. func (s *StreamComponent) GetRenderedContent() string { // Commit any pending chunks so the final output is complete. s.commitPending() @@ -323,7 +371,19 @@ func (s *StreamComponent) GetRenderedContent() string { if len(sections) == 0 { return "" } - return strings.Join(sections, "\n") + fullContent := strings.Join(sections, "\n") + + // Skip lines already emitted to the terminal scrollback via ConsumeOverflow + // so the caller doesn't re-print content that is already there. + if s.scrollbackFlushedLines > 0 { + lines := strings.Split(fullContent, "\n") + if s.scrollbackFlushedLines >= len(lines) { + return "" // everything already in scrollback + } + return strings.Join(lines[s.scrollbackFlushedLines:], "\n") + } + + return fullContent } // commitPending moves any pending chunks to the committed content builders.