diff --git a/pkg/tui/components/markdown/incremental_test.go b/pkg/tui/components/markdown/incremental_test.go index 6ba9c48f41..c19ab4c31f 100644 --- a/pkg/tui/components/markdown/incremental_test.go +++ b/pkg/tui/components/markdown/incremental_test.go @@ -2,13 +2,57 @@ package markdown import ( _ "embed" + "strconv" "strings" "testing" + "unicode/utf8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestIncrementalRenderedPartsPreserveMarkdownSemantics(t *testing.T) { + t.Parallel() + cases := map[string]string{"long heading": "# A long heading that wraps across multiple terminal rows while retaining heading styling and continuation indentation", "unordered list": "- first list item with enough words to wrap onto a continuation line that stays indented\n- second item", "ordered list": "1. first ordered item with enough words to wrap onto a continuation line that stays indented\n2. second item", "mixed blocks": "# Heading\n\nOpening **paragraph** with a [link](https://example.com).\n\n- list item\n continued content\n\n1. ordered item\n2. next item\n\nFinal `code` paragraph."} + for name, input := range cases { + t.Run(name, func(t *testing.T) { + for _, width := range []int{24, 47, 80} { + r := NewIncrementalRenderer(width) + for end := 1; end <= len(input); end++ { + parts, err := r.RenderParts(input[:end]) + require.NoError(t, err) + got := r.joinPrefixAndTail(parts.StablePrefix, parts.MutableTail) + want, err := NewFastRenderer(width).Render(input[:end]) + require.NoError(t, err) + require.Equal(t, want, got, "width %d byte %d", width, end) + } + } + }) + } +} + +func TestIncrementalRenderedPartsMatchOneShotAtEveryMarkdownBoundary(t *testing.T) { + const input = "Thinking… λ界\n\n# Heading\n\nParagraph with **bold**, `more`, and [link](https://example.com).\n\n- one\n- two\n\n```console\nroot\nmore\n```\n\n## Result\n\nDone." + for _, width := range []int{24, 47, 80} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + r := NewIncrementalRenderer(width) + for end := range len(input) + 1 { + if end < len(input) && !utf8.RuneStart(input[end]) { + continue + } + prefix := input[:end] + parts, err := r.RenderParts(prefix) + require.NoError(t, err) + got := r.joinPrefixAndTail(parts.StablePrefix, parts.MutableTail) + want, wantBlocks, err := NewFastRenderer(width).RenderWithCodeBlocks(prefix) + require.NoError(t, err) + require.Equal(t, want, got, "byte boundary %d", end) + require.Equal(t, wantBlocks, parts.CodeBlocks, "code blocks at byte boundary %d", end) + } + }) + } +} + func TestIncrementalRenderedPartsMatchJoinedOutputAtEveryStep(t *testing.T) { t.Parallel() chunks := []string{"unfinished *em", "phasis* and [li", "nk](https://example.com)\n\n", "```go\nfmt.Print(\"λ界\")", "\n```\n\n- one", "\n- two\n\nfinal"} diff --git a/pkg/tui/components/message/append_content_test.go b/pkg/tui/components/message/append_content_test.go new file mode 100644 index 0000000000..6dcf66e35e --- /dev/null +++ b/pkg/tui/components/message/append_content_test.go @@ -0,0 +1,153 @@ +package message + +import ( + "bytes" + "encoding/base64" + stdimage "image" + "image/color" + "image/png" + "strconv" + "strings" + "testing" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/animation" + tuiimage "github.com/docker/docker-agent/pkg/tui/image" + "github.com/docker/docker-agent/pkg/tui/types" +) + +func testImageURI(t *testing.T, colorValue color.RGBA) string { + t.Helper() + img := stdimage.NewRGBA(stdimage.Rect(0, 0, 2, 1)) + img.Set(0, 0, colorValue) + var data bytes.Buffer + require.NoError(t, png.Encode(&data, img)) + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data.Bytes()) +} + +func TestAppendContentPreservesSplitImageOpenerAndExactContent(t *testing.T) { + msg := types.Agent(types.MessageTypeAssistant, "root", "prefix ") + m := New(animation.NewRuntime(), msg, nil) + for _, chunk := range []string{"!", "[alt]", "(https://example.com/image.png)", " suffix"} { + _ = m.AppendContent(chunk) + } + require.Equal(t, "prefix ![alt](https://example.com/image.png) suffix", m.message.Content) + require.Equal(t, "prefix ![alt](https://example.com/image.png) suffix", m.contentBuf.String()) +} + +func TestAppendContentSplitImageSchedulesAndRendersInline(t *testing.T) { + tuiimage.SetRenderingEnabled(true) + uri := testImageURI(t, color.RGBA{G: 255, A: 255}) + m := New(animation.NewRuntime(), types.Agent(types.MessageTypeAssistant, "root", ""), nil) + m.SetSize(80, 0) + chunks := []string{"!", "[alt]", "(" + uri + ")"} + for _, chunk := range chunks[:2] { + require.Nil(t, m.AppendContent(chunk)) + } + cmd := m.AppendContent(chunks[2]) + require.NotNil(t, cmd, "completing a pending image reference must schedule loading") + _, _ = m.Update(cmd()) + view := m.View() + require.Contains(t, view, "cagent-image") + require.Contains(t, ansi.Strip(view), "alt") + require.NotContains(t, ansi.Strip(view), strings.Join(chunks, "")) +} + +func TestAppendContentCompletesInitialImageAcrossPartitions(t *testing.T) { + uri := testImageURI(t, color.RGBA{R: 128, G: 64, A: 255}) + full := "prefix ![界](" + uri + ") suffix" + for split := range len(full) + 1 { + if split == 0 || split == len(full) || !utf8.RuneStart(full[split]) { + continue + } + initial, appended := full[:split], full[split:] + if len(tuiimage.MarkdownReferences(initial)) != 0 || !strings.Contains(initial, "![") { + continue + } + t.Run(strconv.Itoa(split), func(t *testing.T) { + m := New(animation.NewRuntime(), types.Agent(types.MessageTypeAssistant, "root", initial), nil) + _ = m.Init() + require.GreaterOrEqual(t, m.imageScanOffset, 0) + cmd := m.AppendContent(appended) + require.NotNil(t, cmd, "partition %d must schedule the completed initial image", split) + _, _ = m.Update(cmd()) + require.Contains(t, m.markdownImages, uri) + require.Equal(t, full, m.message.Content) + }) + } +} + +func TestAppendContentInitialUnresolvedImageForms(t *testing.T) { + uri := testImageURI(t, color.RGBA{B: 128, A: 255}) + for _, tc := range []struct { + initial, appended string + }{ + {"![alt", "](" + uri + ")"}, + {"prefix ![界", "](" + uri + ")"}, + {"![alt](", uri + ")"}, + } { + m := New(animation.NewRuntime(), types.Agent(types.MessageTypeAssistant, "root", tc.initial), nil) + _ = m.Init() + require.GreaterOrEqual(t, m.imageScanOffset, 0) + cmd := m.AppendContent(tc.appended) + require.NotNil(t, cmd) + _, _ = m.Update(cmd()) + require.Contains(t, m.markdownImages, uri) + } +} + +func TestAppendContentSchedulesSecondImageSplitAfterFirstCompletion(t *testing.T) { + first := testImageURI(t, color.RGBA{R: 255, A: 255}) + second := testImageURI(t, color.RGBA{B: 255, A: 255}) + m := New(animation.NewRuntime(), types.Agent(types.MessageTypeAssistant, "root", ""), nil) + + cmd := m.AppendContent("![one](" + first + ") ![two") + require.NotNil(t, cmd) + _, _ = m.Update(cmd()) + require.GreaterOrEqual(t, m.imageScanOffset, 0, "incomplete second opener remains tracked") + + cmd = m.AppendContent("](" + second + ")") + require.NotNil(t, cmd, "completing the second image must schedule loading") + _, _ = m.Update(cmd()) + require.Contains(t, m.markdownImages, first) + require.Contains(t, m.markdownImages, second) + require.Equal(t, -1, m.imageScanOffset) +} + +func TestAppendContentSplitImageSyntaxInCodeDoesNotFetch(t *testing.T) { + for _, chunks := range [][]string{ + {"`!", "[inline]", "(https://example.com/inline.png)`"}, + {"```md\n!", "[fenced]", "(https://example.com/fenced.png)\n```"}, + } { + m := New(animation.NewRuntime(), types.Agent(types.MessageTypeAssistant, "root", ""), nil) + for _, chunk := range chunks { + require.Nil(t, m.AppendContent(chunk)) + } + require.Empty(t, m.loadingImages) + } +} + +func TestAppendContentImageScanStateIsBoundedAndCleared(t *testing.T) { + m := New(animation.NewRuntime(), types.Agent(types.MessageTypeAssistant, "root", ""), nil) + long := "![abandoned" + strings.Repeat("界", 100_000) + require.Nil(t, m.AppendContent(long)) + require.Equal(t, 0, m.imageScanOffset) + require.Equal(t, len(long), m.contentBuf.Len(), "scan state must not duplicate the canonical suffix") + + m.Finalize() + require.Equal(t, -1, m.imageScanOffset) + _ = m.SetMessage(types.Agent(types.MessageTypeAssistant, "root", "reset")) + require.Equal(t, -1, m.imageScanOffset) +} + +func TestAppendContentImageScanOffsetIsUTF8Safe(t *testing.T) { + m := New(animation.NewRuntime(), types.Agent(types.MessageTypeAssistant, "root", ""), nil) + require.Nil(t, m.AppendContent("界!")) + require.Equal(t, -1, m.imageScanOffset) + require.Nil(t, m.AppendContent("[界")) + require.Equal(t, len("界"), m.imageScanOffset) + require.Equal(t, "![界", m.message.Content[m.imageScanOffset:]) +} diff --git a/pkg/tui/components/message/message.go b/pkg/tui/components/message/message.go index 831b674310..d9f99a0170 100644 --- a/pkg/tui/components/message/message.go +++ b/pkg/tui/components/message/message.go @@ -7,6 +7,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/docker/docker-agent/pkg/tui/animation" @@ -28,6 +29,7 @@ type Model interface { layout.Model layout.Sizeable SetMessage(msg *types.Message) tea.Cmd + AppendContent(content string) tea.Cmd SetSelected(selected bool) SetHovered(hovered bool) CodeBlocks() []markdown.CodeBlock @@ -41,6 +43,19 @@ type Model interface { // to assert that finalized views have actually released their per-message // render state without reaching into unexported fields via reflection. HasLiveRenderState() bool + // RenderedSegments exposes immutable header/stable blocks separately from + // the mutable tail so transcript follow-tail rendering need not flatten the + // complete active response on every chunk. + RenderedSegments(width int) (AssistantSegments, bool) +} + +// AssistantSegments is a line-oriented active assistant rendering. Header and +// Stable are retained by the message view and immutable until the next width +// change; Tail contains only the mutable markdown block. +type AssistantSegments struct { + Header []string + Stable []string + Tail []string } // messageModel implements Model @@ -72,7 +87,11 @@ type messageModel struct { // mdRenderer is reused across renders of an assistant message so that // streamed-in chunks only re-render the trailing block instead of the whole // accumulated markdown each time. - mdRenderer *markdown.IncrementalRenderer + mdRenderer *markdown.IncrementalRenderer + streamLines assistantStreamLines + segmentCodeBlocks []markdown.CodeBlock + contentBuf strings.Builder + imageScanOffset int // finalized is set by Finalize() once the message is no longer the active // streaming view. After it is set, Render() still produces correct output, @@ -87,6 +106,14 @@ type messageModel struct { markdownImageID int } +type assistantStreamLines struct { + width int + stable string + stableLines []string + headerKey string + headerLines []string +} + type markdownImagesLoadedMsg struct { target *messageModel requested []tuiimage.MarkdownReference @@ -119,13 +146,19 @@ type renderCache struct { // New creates a new message view func New(ar *animation.Runtime, msg, previous *types.Message) *messageModel { + imageScanOffset := -1 + if msg != nil && msg.Type == types.MessageTypeAssistant { + refs := tuiimage.MarkdownReferences(msg.Content) + imageScanOffset = nextUnresolvedImageOpener(msg.Content, 0, refs) + } return &messageModel{ - message: msg, - previous: previous, - width: 80, // Default width - height: 1, // Will be calculated - focused: false, - spinner: spinner.New(ar, spinner.ModeBoth, styles.SpinnerDotsAccentStyle), + message: msg, + previous: previous, + width: 80, // Default width + height: 1, // Will be calculated + focused: false, + imageScanOffset: imageScanOffset, + spinner: spinner.New(ar, spinner.ModeBoth, styles.SpinnerDotsAccentStyle), } } @@ -158,15 +191,79 @@ func (mv *messageModel) SetMessage(msg *types.Message) tea.Cmd { mv.mdRenderer.Reset() } mv.message = msg + mv.contentBuf.Reset() + if msg != nil { + mv.contentBuf.WriteString(msg.Content) + } + mv.imageScanOffset = -1 + mv.renderCache.valid = false + if msg == nil || msg.Type != types.MessageTypeAssistant { + return nil + } + refs := tuiimage.MarkdownReferences(msg.Content) + mv.imageScanOffset = nextUnresolvedImageOpener(msg.Content, 0, refs) + return mv.loadMarkdownImageReferences(refs) +} + +func (mv *messageModel) AppendContent(content string) tea.Cmd { + if content == "" || mv.message == nil { + return nil + } + if mv.contentBuf.Len() == 0 && mv.message.Content != "" { + mv.contentBuf.WriteString(mv.message.Content) + } + oldLen := mv.contentBuf.Len() + mv.contentBuf.WriteString(content) + mv.message.Content = mv.contentBuf.String() mv.renderCache.valid = false - return mv.loadMarkdownImages(msg) + // Keep only an offset into canonical content. The one-byte lookback finds an + // opener split as "!" then "[" without retaining or duplicating streamed text. + if mv.imageScanOffset < 0 { + scanStart := max(oldLen-1, 0) + if relative := strings.Index(mv.message.Content[scanStart:], "!["); relative >= 0 { + mv.imageScanOffset = scanStart + relative + } + } + if mv.imageScanOffset < 0 { + return nil + } + // Parse the complete document so Markdown context (notably inline and + // fenced code) decides whether a raw opener is actually an image. + refs := tuiimage.MarkdownReferences(mv.message.Content) + mv.imageScanOffset = nextUnresolvedImageOpener(mv.message.Content, mv.imageScanOffset, refs) + return mv.loadMarkdownImageReferences(refs) +} + +func nextUnresolvedImageOpener(content string, start int, refs []tuiimage.MarkdownReference) int { + for start < len(content) { + relative := strings.Index(content[start:], "![") + if relative < 0 { + return -1 + } + opener := start + relative + resolved := false + for _, ref := range refs { + if ref.Start <= opener && opener < ref.End { + resolved = true + break + } + } + if !resolved { + return opener + } + start = opener + 2 + } + return -1 } func (mv *messageModel) loadMarkdownImages(msg *types.Message) tea.Cmd { if msg == nil || msg.Type != types.MessageTypeAssistant { return nil } - refs := tuiimage.MarkdownReferences(msg.Content) + return mv.loadMarkdownImageReferences(tuiimage.MarkdownReferences(msg.Content)) +} + +func (mv *messageModel) loadMarkdownImageReferences(refs []tuiimage.MarkdownReference) tea.Cmd { pending := make([]tuiimage.MarkdownReference, 0, len(refs)) if mv.loadingImages == nil { mv.loadingImages = make(map[string]bool) @@ -260,6 +357,79 @@ func (mv *messageModel) IsToggleLine(lineIdx int) bool { return lineIdx >= height-3 } +func (mv *messageModel) RenderedSegments(width int) (AssistantSegments, bool) { + msg := mv.message + if msg == nil || msg.Type != types.MessageTypeAssistant || msg.Content == "" || mv.selected || len(mv.markdownImages) != 0 { + return AssistantSegments{}, false + } + messageStyle := styles.AssistantMessageStyle + innerWidth := width - messageStyle.GetHorizontalFrameSize() + if mv.mdRenderer == nil { + mv.mdRenderer = markdown.NewIncrementalRenderer(innerWidth) + } else { + mv.mdRenderer.SetWidth(innerWidth) + } + parts, err := mv.mdRenderer.RenderParts(msg.Content) + if err != nil { + return AssistantSegments{}, false + } + cache := &mv.streamLines + widthChanged := cache.width != width + if widthChanged || !strings.HasPrefix(parts.StablePrefix, cache.stable) { + cache.width, cache.stable = width, "" + cache.stableLines = nil + } + if cache.stable != parts.StablePrefix { + delta := parts.StablePrefix[len(cache.stable):] + if cache.stable != "" && strings.HasPrefix(delta, "\n") { + delta = strings.TrimPrefix(delta, "\n") + } + cache.stableLines = append(cache.stableLines, styledAssistantLines(messageStyle, width, delta)...) + cache.stable = parts.StablePrefix + } + header := actionRow(innerWidth, mv.hovered, types.MessageCopyLabel) + prefix := "" + if !mv.sameAgentAsPrevious(msg) { + prefix = mv.senderPrefix(msg.Sender) + } + headerKey := prefix + header + if cache.headerKey != headerKey || widthChanged { + cache.headerKey = headerKey + cache.headerLines = nil + if prefix != "" { + cache.headerLines = append(cache.headerLines, strings.Split(strings.TrimSuffix(prefix, "\n"), "\n")...) + } + cache.headerLines = append(cache.headerLines, styledAssistantLines(messageStyle, width, header)...) + } + tailLines := styledAssistantLines(messageStyle, width, parts.MutableTail) + if parts.MutableTail != "" && parts.StablePrefix != "" { + separator := styledAssistantLines(messageStyle, width, strings.Repeat(" ", max(innerWidth, 0))) + tailLines = append(separator, tailLines...) + } + + prefixLines := len(cache.headerLines) + mv.segmentCodeBlocks = nil + if len(parts.CodeBlocks) > 0 { + mv.segmentCodeBlocks = make([]markdown.CodeBlock, len(parts.CodeBlocks)) + } + for i, cb := range parts.CodeBlocks { + mv.segmentCodeBlocks[i] = markdown.CodeBlock{Content: cb.Content, Line: cb.Line + prefixLines} + } + if len(mv.segmentCodeBlocks) == 0 { + mv.codeBlocks = nil + } else { + mv.codeBlocks = append(mv.codeBlocks[:0], mv.segmentCodeBlocks...) + } + return AssistantSegments{Header: cache.headerLines, Stable: cache.stableLines, Tail: tailLines}, true +} + +func styledAssistantLines(style lipgloss.Style, width int, content string) []string { + if content == "" { + return nil + } + return strings.Split(strings.TrimSuffix(style.Width(width).Render(content), "\n"), "\n") +} + // View renders the message view func (mv *messageModel) View() string { return mv.Render(mv.width) @@ -722,6 +892,7 @@ func (mv *messageModel) Finalize() { return } mv.renderCache = renderCache{} + mv.imageScanOffset = -1 if mv.mdRenderer != nil { mv.mdRenderer.Reset() mv.mdRenderer = nil diff --git a/pkg/tui/components/message/message_test.go b/pkg/tui/components/message/message_test.go index cccb24d725..02490455ab 100644 --- a/pkg/tui/components/message/message_test.go +++ b/pkg/tui/components/message/message_test.go @@ -8,8 +8,10 @@ import ( "image/color" "image/png" "regexp" + "strconv" "strings" "testing" + "unicode/utf8" "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" @@ -23,6 +25,71 @@ import ( var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") +func TestAssistantRenderedSegmentsMatchViewAtEveryMarkdownBoundary(t *testing.T) { + const input = "Thinking… λ界\n\n# Heading\n\nParagraph with **bold**, `more`, and [link](https://example.com).\n\n- one\n- two\n\n```console\nroot\nmore\n```\n\n## Result\n\nDone." + for _, width := range []int{24, 47, 80} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + msg := types.Agent(types.MessageTypeAssistant, "root", "") + m := New(animation.NewRuntime(), msg, nil) + for end := range len(input) + 1 { + if end < len(input) && !utf8.RuneStart(input[end]) { + continue + } + msg.Content = input[:end] + _ = m.SetMessage(msg) + if msg.Content == "" { + continue + } + segments, ok := m.RenderedSegments(width) + require.True(t, ok) + segmentedBlocks := append([]markdown.CodeBlock(nil), m.CodeBlocks()...) + got := append(append(append([]string{}, segments.Header...), segments.Stable...), segments.Tail...) + want := strings.Split(strings.TrimSuffix(m.Render(width), "\n"), "\n") + oneShotBlocks := append([]markdown.CodeBlock(nil), m.CodeBlocks()...) + require.Equal(t, linePlain(want), linePlain(got), "byte boundary %d", end) + require.Equal(t, lineWidthsForMessage(want), lineWidthsForMessage(got), "widths at byte boundary %d", end) + require.Equal(t, oneShotBlocks, segmentedBlocks, "code block metadata at byte boundary %d", end) + } + }) + } +} + +func linePlain(lines []string) []string { + out := make([]string, len(lines)) + for i, line := range lines { + out[i] = ansi.Strip(line) + } + return out +} + +func lineWidthsForMessage(lines []string) []int { + out := make([]int, len(lines)) + for i, line := range lines { + out[i] = ansi.StringWidth(line) + } + return out +} + +func TestAssistantRenderedSegmentsMatchViewAcrossStreamingBoundariesAndWidth(t *testing.T) { + runtime := animation.NewRuntime() + msg := types.Agent(types.MessageTypeAssistant, "root", "") + m := New(runtime, msg, nil) + chunks := []string{"unfinished *em", "phasis* and [li", "nk](https://example.com)\n\n", "```go\nfmt.Print(\"λ界\")", "\n```\n\n- one", "\n- two\n\nfinal"} + for _, width := range []int{80, 37, 100} { + for _, chunk := range chunks { + msg.Content += chunk + _ = m.SetMessage(msg) + segments, ok := m.RenderedSegments(width) + require.True(t, ok) + lines := make([]string, 0, len(segments.Header)+len(segments.Stable)+len(segments.Tail)) + lines = append(lines, segments.Header...) + lines = append(lines, segments.Stable...) + lines = append(lines, segments.Tail...) + require.Equal(t, strings.Split(strings.TrimSuffix(m.Render(width), "\n"), "\n"), lines) + } + } +} + func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") } @@ -464,3 +531,22 @@ func TestAgentReturnRespectsNarrowWidths(t *testing.T) { } } } + +func TestAssistantRenderedSegmentsRebuildHeaderOnWidthChange(t *testing.T) { + msg := types.Agent(types.MessageTypeAssistant, "root", "streamed response") + m := New(animation.NewRuntime(), msg, nil) + + wide, ok := m.RenderedSegments(80) + require.True(t, ok) + narrow, ok := m.RenderedSegments(32) + require.True(t, ok) + + require.NotEqual(t, lineWidthsForMessage(wide.Header), lineWidthsForMessage(narrow.Header)) + for _, line := range narrow.Header { + require.LessOrEqual(t, ansi.StringWidth(line), 32) + } + want := strings.Split(strings.TrimSuffix(m.Render(32), "\n"), "\n") + got := append(append(append([]string{}, narrow.Header...), narrow.Stable...), narrow.Tail...) + require.Equal(t, linePlain(want), linePlain(got)) + require.Equal(t, lineWidthsForMessage(want), lineWidthsForMessage(got)) +} diff --git a/pkg/tui/components/messages/active_suffix_selection_test.go b/pkg/tui/components/messages/active_suffix_selection_test.go new file mode 100644 index 0000000000..b9194da2f4 --- /dev/null +++ b/pkg/tui/components/messages/active_suffix_selection_test.go @@ -0,0 +1,74 @@ +package messages + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/core/layout" + "github.com/docker/docker-agent/pkg/tui/types" +) + +func activeSuffixSelectionFixture(t *testing.T) *model { + t.Helper() + m := activeHoverStream(t, 120) + // Give the active suffix a real flattened predecessor so crossing the + // ownership boundary exercises both canonical sources. + history := types.User("flattened boundary marker") + m.messages = append([]*types.Message{history}, m.messages...) + m.views = append([]layout.Model{m.createMessageView(history)}, m.views...) + m.invalidateAllItems() + _ = m.View() + m.scrollToBottom() + require.NotNil(t, m.activeSegments) + require.Positive(t, m.activeSegments.start) + require.Len(t, m.renderedLines, m.activeSegments.start) + return m +} + +func findCanonicalLine(t *testing.T, m *model, needle string, start, end int) int { + t.Helper() + for line := start; line < end; line++ { + if strings.Contains(ansi.Strip(m.renderedLine(line)), needle) { + return line + } + } + t.Fatalf("canonical line containing %q not found in [%d,%d)", needle, start, end) + return -1 +} + +func TestActiveVirtualSuffixWordAndLineSelection(t *testing.T) { + m := activeSuffixSelectionFixture(t) + line := findCanonicalLine(t, m, `fmt.Println("tail")`, m.activeSegments.start, m.totalHeight) + plain := ansi.Strip(m.renderedLine(line)) + col := strings.Index(plain, "Println") + 2 + require.GreaterOrEqual(t, col, 2) + + require.True(t, m.selectWordAt(line, col), "double-click word selection in active suffix") + require.Equal(t, "Println", m.extractSelectedText()) + + require.True(t, m.selectLineAt(line), "triple-click line selection in active suffix") + require.Contains(t, m.extractSelectedText(), `fmt.Println("tail")`) +} + +func TestActiveVirtualSuffixDragCopyAndFlattenedBoundaryRange(t *testing.T) { + m := activeSuffixSelectionFixture(t) + activeLine := findCanonicalLine(t, m, "paragraph", m.activeSegments.start, m.totalHeight) + m.selectRange(activeLine, 0, min(activeLine+2, m.totalHeight-1), m.width) + require.Contains(t, m.extractSelectedText(), "paragraph", "drag beginning in active suffix must copy canonical lines") + + flattenedLine := -1 + for line := m.activeSegments.start - 1; line >= 0; line-- { + if strings.TrimSpace(ansi.Strip(m.renderedLine(line))) != "" { + flattenedLine = line + break + } + } + require.GreaterOrEqual(t, flattenedLine, 0) + m.selectRange(flattenedLine, 0, activeLine, m.width) + copied := m.extractSelectedText() + require.NotEmpty(t, copied) + require.Contains(t, copied, "paragraph", "selection crossing flattened-to-active boundary must include active endpoint") +} diff --git a/pkg/tui/components/messages/clipboard.go b/pkg/tui/components/messages/clipboard.go index 7d0e9e5c4b..737fac9211 100644 --- a/pkg/tui/components/messages/clipboard.go +++ b/pkg/tui/components/messages/clipboard.go @@ -161,19 +161,18 @@ func (m *model) extractSelectedText() string { } m.ensureAllItemsRendered() - lines := m.renderedLines startLine, startCol, endLine, endCol := m.selection.normalized() - if startLine < 0 || startLine >= len(lines) { + if startLine < 0 || startLine >= m.totalHeight { return "" } - if endLine >= len(lines) { - endLine = len(lines) - 1 + if endLine >= m.totalHeight { + endLine = m.totalHeight - 1 } var selected []string - for i := startLine; i <= endLine && i < len(lines); i++ { - originalLine := lines[i] + for i := startLine; i <= endLine && i < m.totalHeight; i++ { + originalLine := m.renderedLine(i) // Strip ANSI codes first to get the displayed text with borders plainLine := ansi.Strip(originalLine) // Strip border characters to get the actual text content diff --git a/pkg/tui/components/messages/deferred_tail_reentry_test.go b/pkg/tui/components/messages/deferred_tail_reentry_test.go new file mode 100644 index 0000000000..650d2f5989 --- /dev/null +++ b/pkg/tui/components/messages/deferred_tail_reentry_test.go @@ -0,0 +1,115 @@ +package messages + +import ( + "bytes" + "encoding/base64" + stdimage "image" + "image/color" + "image/png" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/animation" + "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/types" +) + +func testDeferredTailImageURI(t *testing.T) string { + t.Helper() + img := stdimage.NewRGBA(stdimage.Rect(0, 0, 2, 1)) + img.Set(0, 0, color.RGBA{R: 255, A: 255}) + var data bytes.Buffer + require.NoError(t, png.Encode(&data, img)) + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data.Bytes()) +} + +func deferredTailFixture(t *testing.T) (*model, string) { + t.Helper() + m := NewScrollableView(animation.NewRuntime(), 60, 8, &service.SessionState{}).(*model) + m.SetSize(60, 8) + msg := types.Agent(types.MessageTypeAssistant, "root", strings.Repeat("history line\n", 40)+"tail-start\n") + m.messages = append(m.messages, msg) + m.views = append(m.views, m.createMessageView(msg)) + _ = m.View() + m.scrollToTop() + chunk := strings.Repeat("deferred marker line\n", 16) + m.AppendToLastMessage("root", chunk) + require.NotEmpty(t, m.deferredTail) + return m, chunk +} + +func TestDeferredTailMaterializesWhenDownwardRangeIntersects(t *testing.T) { + for _, tc := range []struct { + name string + move func(*model) + }{ + {"line-down", func(m *model) { m.scrollDown() }}, + {"page-down", func(m *model) { m.scrollPageDown() }}, + {"wheel-down", func(m *model) { m.scrollByWheel(1) }}, + {"end", func(m *model) { m.scrollToBottom() }}, + } { + t.Run(tc.name, func(t *testing.T) { + m, chunk := deferredTailFixture(t) + if tc.name != "end" { + // Put the requested viewport/overscan immediately before the + // stale final item so this operation is the reentry boundary. + start := m.lineOffsets[m.deferredTailIndex] + m.setScrollOffset(max(0, start-m.height)) + } + tc.move(m) + require.Empty(t, m.deferredTail) + require.True(t, strings.HasSuffix(m.messages[len(m.messages)-1].Content, chunk)) + var exactLines []string + for i := range m.totalHeight { + exactLines = append(exactLines, m.renderedLine(i)) + } + require.Contains(t, strings.Join(exactLines, "\n"), "deferred marker line") + }) + } +} + +func TestDeferredTailMaterializesBeforeScrollbarGeometryAndDrag(t *testing.T) { + m, chunk := deferredTailFixture(t) + x := m.scrollview.ScrollbarX() + _, _ = m.handleMouseClick(tea.MouseClickMsg{X: x, Y: m.yPos, Button: tea.MouseLeft}) + require.Empty(t, m.deferredTail, "scrollbar click must use exact total height") + require.True(t, strings.HasSuffix(m.messages[0].Content, chunk)) + + // A motion after grabbing the thumb continues to use reconciled geometry. + _, _ = m.handleMouseMotion(tea.MouseMotionMsg{X: x, Y: m.yPos + m.height - 1}) + _, _ = m.handleMouseRelease(tea.MouseReleaseMsg{X: x, Y: m.yPos + m.height - 1, Button: tea.MouseLeft}) + require.False(t, m.scrollview.IsDragging()) +} + +func TestFinalizeStreamMaterializesDeferredTailWithoutJumping(t *testing.T) { + m, chunk := deferredTailFixture(t) + offset := m.scrollOffset + m.FinalizeStream() + require.Empty(t, m.deferredTail) + require.Equal(t, offset, m.scrollOffset, "finalization preserves the scrolled-up viewport") + require.True(t, strings.HasSuffix(m.messages[0].Content, chunk)) +} + +func TestFinalizeStreamReturnsDeferredTailImageLoadCommand(t *testing.T) { + m, _ := deferredTailFixture(t) + uri := testDeferredTailImageURI(t) + m.AppendToLastMessage("root", "![deferred image]("+uri+")") + + cmd := m.FinalizeStream() + require.NotNil(t, cmd, "materializing a deferred image must propagate SetMessage's load command") + loaded := cmd() + require.NotNil(t, loaded) + _, updateCmd := m.Update(loaded) + if updateCmd != nil { + _ = updateCmd() + } + view := m.views[m.deferredTailIndexForTest()].View() + require.Contains(t, view, "cagent-image") +} + +func (m *model) deferredTailIndexForTest() int { + return len(m.views) - 1 +} diff --git a/pkg/tui/components/messages/hover_stream_regression_test.go b/pkg/tui/components/messages/hover_stream_regression_test.go new file mode 100644 index 0000000000..45e49188d9 --- /dev/null +++ b/pkg/tui/components/messages/hover_stream_regression_test.go @@ -0,0 +1,178 @@ +package messages + +import ( + "fmt" + "strconv" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/animation" + "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/types" +) + +func activeHoverStream(t *testing.T, paragraphs int) *model { + t.Helper() + m := NewScrollableView(animation.NewRuntime(), 80, 12, &service.SessionState{}).(*model) + m.SetSize(80, 12) + content := "## stream\n\n" + strings.Repeat("paragraph **bold** [link](https://example.com) λ界\n\n", paragraphs) + "```go\nfmt.Println(\"tail\")\n```\n" + msg := types.Agent(types.MessageTypeAssistant, "root", content) + m.messages = append(m.messages, msg) + m.views = append(m.views, m.createMessageView(msg)) + _ = m.View() + m.scrollToBottom() + _ = m.View() + return m +} + +func lineWidths(s string) []int { + lines := strings.Split(strings.TrimSuffix(s, "\n"), "\n") + widths := make([]int, len(lines)) + for i, line := range lines { + widths[i] = ansi.StringWidth(line) + } + return widths +} + +func TestVirtualActiveSuffixContentIntersectsFlattenedPrefixBoundary(t *testing.T) { + m := activeHoverStream(t, 160) + require.NotNil(t, m.activeSegments) + m.scrollOffset = max(0, len(m.renderedLines)-m.height/2) + frame := m.View() + plain := ansi.Strip(frame) + require.NotEmpty(t, strings.TrimSpace(plain), "viewport intersecting virtual suffix blanked") + require.Contains(t, plain, "paragraph", "stable active prefix disappeared") + require.Equal(t, m.height-1, strings.Count(frame, "\n")) +} + +func TestVirtualActiveSuffixMovementMatrixNeverBlanks(t *testing.T) { + moves := []struct { + name string + move func(*model) + }{ + {"wheel-up-down", func(m *model) { m.scrollByWheel(-1); m.scrollByWheel(1) }}, + {"page-up-down", func(m *model) { m.scrollPageUp(); m.scrollPageDown() }}, + {"key-home-end", func(m *model) { + _, _ = m.handleKeyPress(tea.KeyPressMsg{Code: 'g'}) + _, _ = m.handleKeyPress(tea.KeyPressMsg{Code: 'G'}) + }}, + {"line-up-down", func(m *model) { m.scrollUp(); m.scrollDown() }}, + {"scrollbar-drag", func(m *model) { + x := m.scrollview.ScrollbarX() + _, _ = m.handleMouseClick(tea.MouseClickMsg{X: x, Y: m.yPos + m.height - 1, Button: tea.MouseLeft}) + _, _ = m.handleMouseMotion(tea.MouseMotionMsg{X: x, Y: m.yPos}) + _, _ = m.handleMouseMotion(tea.MouseMotionMsg{X: x, Y: m.yPos + m.height - 1}) + _, _ = m.handleMouseRelease(tea.MouseReleaseMsg{X: x, Y: m.yPos + m.height - 1, Button: tea.MouseLeft}) + }}, + } + for _, tc := range moves { + t.Run(tc.name, func(t *testing.T) { + m := activeHoverStream(t, 160) + tc.move(m) + m.scrollToBottom() + frame := m.View() + plain := ansi.Strip(frame) + require.NotEmpty(t, strings.TrimSpace(plain), "intersecting virtual suffix viewport blanked") + require.Contains(t, plain, `fmt.Println("tail")`, "exact bottom dropped active tail") + require.Equal(t, m.height-1, strings.Count(frame, "\n"), "viewport height") + for _, width := range lineWidths(frame) { + require.Equal(t, m.width, width, "viewport width") + } + require.Equal(t, m.totalHeight-m.height, m.scrollOffset, "exact bottom offset") + require.Equal(t, m.totalHeight, m.activeSegments.start+m.activeSegments.height(), "active segment boundary") + }) + } +} + +func TestPendingAssistantHoverDoesNotChangeFrameGeometry(t *testing.T) { + m := NewScrollableView(animation.NewRuntime(), 48, 6, &service.SessionState{}).(*model) + m.SetSize(48, 6) + msg := types.Agent(types.MessageTypeAssistant, "root", "") + m.messages = append(m.messages, msg) + m.views = append(m.views, m.createMessageView(msg)) + before := m.View() + beforeTotal, beforeOffset, beforeDeferred := m.totalHeight, m.scrollOffset, len(m.deferredTail) + + for range 20 { + _, _ = m.handleMouseMotion(tea.MouseMotionMsg{X: 12, Y: 0}) + hovered := m.View() + require.Equal(t, lineWidths(before), lineWidths(hovered)) + require.Equal(t, strings.Count(before, "\n"), strings.Count(hovered, "\n")) + require.NotEmpty(t, strings.TrimSpace(ansi.Strip(hovered)), "pending spinner must remain visible") + _, _ = m.handleMouseMotion(tea.MouseMotionMsg{X: 12, Y: m.height + 2}) + require.Equal(t, before, m.View(), "same elapsed animation state must be hover-isolated") + require.Equal(t, beforeTotal, m.totalHeight) + require.Equal(t, beforeOffset, m.scrollOffset) + require.Len(t, m.deferredTail, beforeDeferred) + } +} + +func TestActiveStreamHoverPreservesFollowGeometry(t *testing.T) { + m := activeHoverStream(t, 160) + require.False(t, m.userHasScrolled) + beforeTotal, beforeOffset := m.totalHeight, m.scrollOffset + beforeMax := max(0, m.totalScrollableHeight()-m.height) + beforeViewport := m.View() + beforeItem := m.renderItem(0, m.views[0]) + require.NotNil(t, beforeItem.segments) + + line := m.totalHeight - 2 + _, _ = m.handleMouseMotion(tea.MouseMotionMsg{X: 20, Y: line - m.scrollOffset}) + afterViewport := m.View() + afterItem := m.renderItem(0, m.views[0]) + + require.Equal(t, 0, m.hoveredMessageIndex) + require.NotNil(t, afterItem.segments, "hover must retain the bounded segmented stream path") + require.Equal(t, beforeItem.height, afterItem.height, "message height") + require.Equal(t, lineWidths(beforeViewport), lineWidths(afterViewport), "viewport wrapping and widths") + require.Equal(t, strings.Count(beforeViewport, "\n"), strings.Count(afterViewport, "\n"), "viewport line count") + require.Equal(t, beforeTotal, m.totalHeight, "transcript total height") + require.Equal(t, beforeOffset, m.scrollOffset) + require.Equal(t, beforeMax, max(0, m.totalScrollableHeight()-m.height)) + require.False(t, m.userHasScrolled) + require.NotEqual(t, strings.Join(beforeItem.segments.Header, "\n"), strings.Join(afterItem.segments.Header, "\n"), "reserved action row should reveal hover control") + require.Equal(t, lineWidths(strings.Join(beforeItem.segments.Header, "\n")), lineWidths(strings.Join(afterItem.segments.Header, "\n"))) + require.Contains(t, ansi.Strip(strings.Join(afterItem.segments.Header, "\n")), "copy") +} + +func TestDeferredTailBottomReentryMaterializesAndRefreshesOnce(t *testing.T) { + for _, paragraphs := range []int{40, 400} { + t.Run(strconv.Itoa(paragraphs), func(t *testing.T) { + m := activeHoverStream(t, paragraphs) + _, _ = m.handleMouseMotion(tea.MouseMotionMsg{X: 20, Y: m.height - 2}) + _ = m.View() + m.scrollPageUp() + require.True(t, m.userHasScrolled) + var deferred strings.Builder + for i := range 100 { + chunk := fmt.Sprintf("deferred-%03d **bold** [link](https://example.com) λ界\n\n", i) + deferred.WriteString(chunk) + m.AppendToLastMessage("root", chunk) + } + require.NotEmpty(t, m.deferredTail) + + m.scrollToBottom() + _ = m.View() + require.Empty(t, m.deferredTail, "bottom reentry materializes the deferred tail") + require.Equal(t, -1, m.deferredTailIndex) + require.NotNil(t, m.activeSegments, "materialized tail retains segmented representation") + require.False(t, m.userHasScrolled) + require.True(t, strings.HasSuffix(m.messages[0].Content, deferred.String())) + + stableLen := len(m.activeSegments.stable) + for i := range 20 { + m.AppendToLastMessage("root", fmt.Sprintf("follow-%03d `code`\n\n", i)) + _ = m.View() + } + require.Empty(t, m.deferredTail) + require.NotNil(t, m.activeSegments) + require.GreaterOrEqual(t, len(m.activeSegments.stable), stableLen, "completed stable lines are retained") + require.LessOrEqual(t, len(m.activeSegments.tail), 32, "mutable suffix remains bounded") + require.False(t, m.userHasScrolled) + }) + } +} diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index 161f63603a..c5d95126ff 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -102,7 +102,11 @@ type Model interface { RemoveSpinner() ScrollToBottom() tea.Cmd + // FinalizeStream materializes any offscreen active tail before stream + // completion/cancellation makes message content externally observable. + FinalizeStream() tea.Cmd AdjustBottomSlack(delta int) + // VisualGeneration increments only when Update changes rendered output. VisualGeneration() uint64 // IsScrollbarDragging returns true when the scrollbar thumb is being dragged. @@ -126,8 +130,35 @@ type Model interface { // renderedItem represents a cached rendered message with position information type renderedItem struct { - lines []string // Pre-split rendered lines (shared with the joined renderedLines slice) - height int // Height in lines + lines []string // Pre-split rendered lines (shared with the joined renderedLines slice) + segments *message.AssistantSegments + height int // Height in lines +} + +type activeTranscriptSegments struct { + index int + start int + header []string + stable []string + tail []string +} + +func (s *activeTranscriptSegments) height() int { + if s == nil { + return 0 + } + return len(s.header) + len(s.stable) + len(s.tail) +} + +func (s *activeTranscriptSegments) line(local int) string { + if local < len(s.header) { + return s.header[local] + } + local -= len(s.header) + if local < len(s.stable) { + return s.stable[local] + } + return s.tail[local-len(s.stable)] } // renderedItemsCacheSize is the initial bound on the number of message @@ -159,13 +190,15 @@ type model struct { scrollOffset int // Current scroll position in lines bottomSlack int // Extra blank lines added after content shrinks slackAnimationSub animation.Subscription // Subscription to animation ticks while slack > 0 - renderedLines []string // Cached rendered content as lines (avoids split/join per frame) + renderedLines []string // Cached flattened content excluding a segmented active suffix + activeSegments *activeTranscriptSegments // Segmented final assistant item while visibly streaming renderedItems *lrucache.LRU[int, renderedItem] // LRU cache of rendered items (bounded to renderedItemsCacheSize) urlSpans *urlSpanCache // Cached URL spans per rendered line lineOffsets []int // Prefix-sum: lineOffsets[i] = starting global line of view i totalHeight int // Total height of all content in lines renderDirty bool // True when rendered content needs rebuild - visualGeneration uint64 + + visualGeneration uint64 selection selectionState @@ -175,7 +208,9 @@ type model struct { xPos, yPos int // User scroll state - userHasScrolled bool // True when user manually scrolls away from bottom + userHasScrolled bool // True when user manually scrolls away from bottom + deferredTailIndex int + deferredTail []string // Message selection state selectedMessageIndex int // Index of selected message (-1 = no selection) @@ -251,10 +286,11 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { switch msg := msg.(type) { case messages.StreamCancelledMsg: + finalizeCmd := m.FinalizeStream() m.removeSpinner() m.removePendingToolCallMessages() m.stopReasoningBlockAnimations() - return m, nil + return m, finalizeCmd case tea.WindowSizeMsg: cmds = append(cmds, m.SetSize(msg.Width, msg.Height)) @@ -269,8 +305,8 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return m.handleMouseRelease(msg) case messages.WheelCoalescedMsg: - m.scrollByWheel(msg.Delta) - return m, nil + cmd := m.scrollByWheel(msg.Delta) + return m, cmd case AutoScrollTickMsg: if m.selection.mouseButtonDown && m.selection.active { @@ -289,7 +325,8 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case scrollToBottomMsg: if !m.userHasScrolled { - m.scrollToBottom() + cmd := m.scrollToBottom() + return m, cmd } return m, nil @@ -350,7 +387,10 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { // subscription is registered when the next tick is scheduled. if tick, ok := msg.(animation.TickMsg); ok { cmds = append(cmds, m.handleAnimationTick(tick)) - if tick.Dirty() { + // Tick dirtiness is program-wide. Do not rebuild the entire transcript + // merely because the root/sidebar spinner advanced; only message-owned + // animated content can change this component's lines. + if tick.Dirty() && m.hasAnimatedContent() { m.renderDirty = true } } @@ -358,7 +398,15 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *model) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) { +func (m *model) handleMouseClick(msg tea.MouseClickMsg) (model layout.Model, cmd tea.Cmd) { + var materializeCmd tea.Cmd + defer func() { cmd = tea.Batch(materializeCmd, cmd) }() + // Scrollbar hit-testing and thumb geometry must use the exact tail height. + // Checking the column first avoids materializing for ordinary transcript + // clicks that cannot reach the stale final item. + if msg.X == m.scrollview.ScrollbarX() && msg.Y >= m.yPos && msg.Y < m.yPos+m.height { + materializeCmd = m.materializeDeferredTailForInteraction() + } if m.isMouseOnScrollbar(msg.X, msg.Y) { return m.handleScrollviewUpdate(msg) } @@ -369,7 +417,9 @@ func (m *model) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) line, col := m.mouseToLineCol(msg.X, msg.Y) - if msgIdx, localLine := m.globalLineToMessageLine(line); msgIdx >= 0 { + msgIdx, localLine, interactionCmd := m.globalLineToMessageLine(line) + materializeCmd = tea.Batch(materializeCmd, interactionCmd) + if msgIdx >= 0 { // Check for toggleable blocks (e.g. reasoning block, collapsed long messages) if t, ok := m.views[msgIdx].(toggleableView); ok { if t.IsToggleLine(localLine) { @@ -448,7 +498,17 @@ func (m *model) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) // globalLineToMessageLine maps a global line index to (message index, local line within message). // Returns (-1, -1) if the line doesn't correspond to any message. -func (m *model) globalLineToMessageLine(globalLine int) (msgIdx, localLine int) { +func (m *model) globalLineToMessageLine(globalLine int) (msgIdx, localLine int, cmd tea.Cmd) { + cmd = m.materializeDeferredTailForRange(globalLine, globalLine+1) + msgIdx, localLine = m.globalLineToMessageLineCached(globalLine) + return msgIdx, localLine, cmd +} + +// globalLineToMessageLineCached maps against the currently owned transcript +// geometry without reconciling a deferred streaming tail. Pointer hover is a +// visual-only operation: it may restyle an already materialized line, but it +// must not make offscreen content become geometry. +func (m *model) globalLineToMessageLineCached(globalLine int) (msgIdx, localLine int) { m.ensureAllItemsRendered() if len(m.lineOffsets) == 0 || globalLine < 0 || globalLine >= m.totalHeight { @@ -464,9 +524,16 @@ func (m *model) globalLineToMessageLine(globalLine int) (msgIdx, localLine int) return -1, -1 } - item := m.renderItem(i, m.views[i]) - local := globalLine - m.lineOffsets[i] - if local < item.height { + start := m.lineOffsets[i] + end := m.totalHeight + if i+1 < len(m.lineOffsets) { + end = m.lineOffsets[i+1] + } + if m.needsSeparator(i) && end > start && end <= len(m.renderedLines) && m.renderedLines[end-1] == "" { + end-- + } + local := globalLine - start + if local >= 0 && globalLine < end { return i, local } @@ -476,7 +543,9 @@ func (m *model) globalLineToMessageLine(globalLine int) (msgIdx, localLine int) func (m *model) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd) { if m.scrollview.IsDragging() { - return m.handleScrollviewUpdate(msg) + materializeCmd := m.materializeDeferredTailForInteraction() + model, cmd := m.handleScrollviewUpdate(msg) + return model, tea.Batch(materializeCmd, cmd) } if m.selection.mouseButtonDown && m.selection.active { @@ -495,7 +564,7 @@ func (m *model) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd // Track hovered message for showing the action labels (copy, edit) line, col := m.mouseToLineCol(msg.X, msg.Y) newHovered := -1 - if msgIdx, _ := m.globalLineToMessageLine(line); msgIdx >= 0 && msgIdx < len(m.messages) { + if msgIdx, _ := m.globalLineToMessageLineCached(line); msgIdx >= 0 && msgIdx < len(m.messages) { switch m.messages[msgIdx].Type { case types.MessageTypeAssistant, types.MessageTypeUser: newHovered = msgIdx @@ -504,13 +573,8 @@ func (m *model) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd if newHovered != m.hoveredMessageIndex { oldHovered := m.hoveredMessageIndex m.hoveredMessageIndex = newHovered - if oldHovered >= 0 { - m.invalidateItem(oldHovered) - } - if newHovered >= 0 { - m.invalidateItem(newHovered) - } - m.renderDirty = true + m.refreshHoverItems(oldHovered, newHovered) + m.visualGeneration++ } // Track hovered URL for underline effect @@ -611,9 +675,9 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) { cmd := m.selectNextMessage() return m, cmd } else { - m.scrollDown() + cmd := m.scrollDown() + return m, cmd } - return m, nil case "c": if m.focused && m.selectedMessageIndex >= 0 { cmd := m.copySelectedMessageToClipboard() @@ -638,14 +702,14 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) { m.scrollPageUp() return m, nil case "pgdown": - m.scrollPageDown() - return m, nil + cmd := m.scrollPageDown() + return m, cmd case "home", "g": m.scrollToTop() return m, nil case "end", "G": - m.scrollToBottom() - return m, nil + cmd := m.scrollToBottom() + return m, cmd } return m, nil } @@ -667,8 +731,9 @@ func (m *model) View() string { return "" } - // Use cached lines directly - O(1) instead of O(totalHeight) split - totalLines := len(m.renderedLines) + m.bottomSlack + // Use virtual total height; a segmented active suffix is intentionally not + // flattened into renderedLines. + totalLines := m.totalHeight + m.bottomSlack if totalLines == 0 { return "" } @@ -684,10 +749,7 @@ func (m *model) View() string { // This is O(viewportHeight) instead of O(totalHeight) visibleLines := make([]string, endLine-startLine) for i := startLine; i < endLine; i++ { - if i < len(m.renderedLines) { - visibleLines[i-startLine] = m.renderedLines[i] - } - // Lines beyond renderedLines are bottom slack (empty strings), already zero-valued + visibleLines[i-startLine] = m.renderedLine(i) } if m.selection.active { @@ -703,9 +765,37 @@ func (m *model) View() string { // memoized line widths instead of re-measuring every visible line. m.scrollview.SetContent(m.renderedLines, m.totalScrollableHeight()) m.scrollview.SetScrollOffset(m.scrollOffset) + // Segmented active lines are not in scrollview's flattened content buffer, + // so use its pre-sliced path. Selection/URL restyling already requires the + // same viewport-local width work. + if m.activeSegments != nil && !m.selection.active && m.hoveredURL == nil && m.copiedFlash == nil { + contentWidth := m.contentWidth() + for i, line := range visibleLines { + switch width := ansi.StringWidth(line); { + case width > contentWidth: + visibleLines[i] = ansi.Truncate(line, contentWidth, "") + case width < contentWidth: + visibleLines[i] = line + strings.Repeat(" ", contentWidth-width) + } + } + return m.scrollview.ViewWithPaddedLines(visibleLines) + } return m.scrollview.ViewWithRestyledLines(visibleLines) } +func (m *model) renderedLine(global int) string { + if global < 0 { + return "" + } + if s := m.activeSegments; s != nil && global >= s.start && global < s.start+s.height() { + return s.line(global - s.start) + } + if global < len(m.renderedLines) { + return m.renderedLines[global] + } + return "" +} + // updateScrollState recomputes rendered content, bottom slack and scroll // offset from the current state of the message list. Called both from View() // and from Update() on animation ticks so that the slack subscription is @@ -779,13 +869,18 @@ func (m *model) SetSize(width, height int) tea.Cmd { } m.invalidateAllItems() + m.visualGeneration++ return nil } func (m *model) SetPosition(x, y int) tea.Cmd { + if m.xPos == x && m.yPos == y { + return nil + } m.xPos = x m.yPos = y m.scrollview.SetPosition(x, y) + m.visualGeneration++ return nil } @@ -831,7 +926,8 @@ func (m *model) FocusAt(x, y int) tea.Cmd { oldIndex := m.selectedMessageIndex line, _ := m.mouseToLineCol(x, y) - if msgIdx, _ := m.globalLineToMessageLine(line); msgIdx >= 0 && m.isSelectableMessage(msgIdx) { + msgIdx, _, materializeCmd := m.globalLineToMessageLine(line) + if msgIdx >= 0 && m.isSelectableMessage(msgIdx) { m.selectedMessageIndex = msgIdx } else { m.selectedMessageIndex = m.findLastAssistantMessage() @@ -850,9 +946,9 @@ func (m *model) FocusAt(x, y int) tea.Cmd { m.renderDirty = true if m.messageTypeChanged(oldIndex, m.selectedMessageIndex) { - return core.CmdHandler(messages.InvalidateStatusBarMsg{}) + return tea.Batch(materializeCmd, core.CmdHandler(messages.InvalidateStatusBarMsg{})) } - return nil + return materializeCmd } // Bindings returns key bindings for the component @@ -918,11 +1014,13 @@ func (m *model) scrollUp() { } } -func (m *model) scrollDown() { +func (m *model) scrollDown() tea.Cmd { + cmd := m.materializeDeferredTailForRange(m.scrollOffset, m.scrollOffset+m.height+defaultScrollAmount) m.setScrollOffset(m.scrollOffset + defaultScrollAmount) if m.isAtBottom() { m.userHasScrolled = false } + return cmd } func (m *model) scrollPageUp() { @@ -931,11 +1029,13 @@ func (m *model) scrollPageUp() { m.setScrollOffset(max(0, m.scrollOffset-m.height)) } -func (m *model) scrollPageDown() { +func (m *model) scrollPageDown() tea.Cmd { + cmd := m.materializeDeferredTailForRange(m.scrollOffset, m.scrollOffset+m.height*2) m.setScrollOffset(m.scrollOffset + m.height) if m.isAtBottom() { m.userHasScrolled = false } + return cmd } func (m *model) scrollToTop() { @@ -944,20 +1044,96 @@ func (m *model) scrollToTop() { m.setScrollOffset(0) } -func (m *model) scrollToBottom() { +func (m *model) materializeDeferredTail() tea.Cmd { + if len(m.deferredTail) == 0 || m.deferredTailIndex < 0 || m.deferredTailIndex >= len(m.messages) { + return nil + } + msg := m.messages[m.deferredTailIndex] + var b strings.Builder + b.Grow(len(msg.Content) + deferredBytes(m.deferredTail)) + b.WriteString(msg.Content) + for _, chunk := range m.deferredTail { + b.WriteString(chunk) + } + msg.Content = b.String() + index := m.deferredTailIndex + cmd := m.views[index].(message.Model).SetMessage(msg) + m.deferredTail = nil + m.deferredTailIndex = -1 + m.refreshRenderedItem(index) + m.visualGeneration++ + return cmd +} + +// materializeDeferredTailForRange reconciles stale geometry only when a +// requested viewport/overscan range can reach the deferred final item. The +// cached line offset is the start of that item and remains valid while chunks +// are deferred; if geometry has not been built yet, materialize conservatively. +// +//nolint:unparam // Range shape is kept explicit for viewport callers. +func (m *model) materializeDeferredTailForRange(start, end int) tea.Cmd { + if len(m.deferredTail) == 0 { + return nil + } + if m.deferredTailIndex < 0 || m.deferredTailIndex >= len(m.lineOffsets) || end > m.lineOffsets[m.deferredTailIndex] { + return m.materializeDeferredTail() + } + return nil +} + +func (m *model) materializeDeferredTailForInteraction() tea.Cmd { + if len(m.deferredTail) != 0 { + cmd := m.materializeDeferredTail() + m.updateScrollState() + m.scrollview.SetContent(m.renderedLines, m.totalScrollableHeight()) + m.scrollview.SetScrollOffset(m.scrollOffset) + return cmd + } + return nil +} + +// FinalizeStream establishes the exact externally visible content boundary +// even when the user remains scrolled above the active response. +func (m *model) FinalizeStream() tea.Cmd { + return m.materializeDeferredTailForInteraction() +} + +func deferredBytes(chunks []string) int { + n := 0 + for _, chunk := range chunks { + n += len(chunk) + } + return n +} + +func (m *model) scrollToBottom() tea.Cmd { + hadDeferredTail := len(m.deferredTail) != 0 + cmd := m.materializeDeferredTail() m.userHasScrolled = false + // A non-deferred final item may still be stale (for example after a hover + // transition). Materialization already refreshed a deferred item, so never + // render it a second time at this re-entry boundary. + if !hadDeferredTail && len(m.views) > 0 { + m.refreshRenderedItem(len(m.views) - 1) + } m.setScrollOffset(9_999_999) // Will be clamped in View() + return cmd } -func (m *model) scrollByWheel(delta int) { +func (m *model) scrollByWheel(delta int) tea.Cmd { if delta == 0 { - return + return nil + } + var cmd tea.Cmd + if delta > 0 { + requestedEnd := m.scrollOffset + m.height + delta*wheelScrollAmount*defaultScrollAmount + cmd = m.materializeDeferredTailForRange(m.scrollOffset, requestedEnd) } prevOffset := m.scrollOffset m.setScrollOffset(m.scrollOffset + (delta * wheelScrollAmount * defaultScrollAmount)) if m.scrollOffset == prevOffset { - return + return cmd } if delta < 0 { @@ -966,6 +1142,7 @@ func (m *model) scrollByWheel(delta int) { } else if m.isAtBottom() { m.userHasScrolled = false } + return cmd } func (m *model) setScrollOffset(offset int) { @@ -1182,6 +1359,15 @@ func (m *model) renderItem(index int, view layout.Model) renderedItem { } } + if v, ok := view.(message.Model); ok { + if segments, ok := v.RenderedSegments(m.contentWidth()); ok { + item := renderedItem{segments: &segments, height: len(segments.Header) + len(segments.Stable) + len(segments.Tail)} + if shouldCache { + m.renderedItems.Put(index, item) + } + return item + } + } rendered := view.View() var lines []string if rendered != "" { @@ -1256,7 +1442,7 @@ func (m *model) needsSeparator(index int) bool { } func (m *model) ensureAllItemsRendered() { - if !m.renderDirty && len(m.renderedLines) > 0 { + if !m.renderDirty && (len(m.renderedLines) > 0 || m.activeSegments != nil) { return } @@ -1272,30 +1458,140 @@ func (m *model) ensureAllItemsRendered() { } var allLines []string + m.activeSegments = nil offsets := make([]int, len(m.views)) + virtualHeight := 0 for i, view := range m.views { - offsets[i] = len(allLines) + offsets[i] = virtualHeight item := m.renderItem(i, view) - if len(item.lines) == 0 { + if item.height == 0 { continue } - - allLines = append(allLines, item.lines...) + if item.segments != nil && i == len(m.views)-1 { + m.activeSegments = &activeTranscriptSegments{index: i, start: virtualHeight, header: item.segments.Header, stable: item.segments.Stable, tail: item.segments.Tail} + virtualHeight += item.height + } else { + if item.segments != nil { + allLines = append(allLines, item.segments.Header...) + allLines = append(allLines, item.segments.Stable...) + allLines = append(allLines, item.segments.Tail...) + } else { + allLines = append(allLines, item.lines...) + } + virtualHeight += item.height + } if m.needsSeparator(i) { allLines = append(allLines, "") + virtualHeight++ } } - // Store lines directly - avoid join/split on every View() call m.renderedLines = allLines m.lineOffsets = offsets - m.totalHeight = len(allLines) + m.totalHeight = virtualHeight m.urlSpans.clear() m.renderDirty = false } +//nolint:unparam // Boolean result is retained for cache-refresh callers. +func (m *model) refreshRenderedItem(index int) bool { + wasAtBottom := m.isAtBottom() + if m.renderDirty || (len(m.renderedLines) == 0 && m.activeSegments == nil) || len(m.lineOffsets) != len(m.views) || index < 0 || index >= len(m.views) { + m.invalidateItem(index) + return false + } + start := m.lineOffsets[index] + end := m.totalHeight + if index+1 < len(m.lineOffsets) { + end = m.lineOffsets[index+1] + } + if m.needsSeparator(index) && end > start && m.renderedLine(end-1) == "" { + end-- + } + m.renderedItems.Delete(index) + item := m.renderItem(index, m.views[index]) + if item.segments != nil && index == len(m.views)-1 { + // The final assistant has exactly one line owner. It may previously have + // been flattened (selection/full Render) or virtual (stream segmentation), + // so discard every flattened line at and after its canonical offset before + // installing the segmented representation. Keeping either the old flattened + // suffix or a shortened prefix makes renderedLines, activeSegments and + // totalHeight describe incompatible coordinate spaces. + if start < 0 || start > len(m.renderedLines) { + m.renderDirty = true + return false + } + m.renderedLines = m.renderedLines[:start] + m.activeSegments = &activeTranscriptSegments{index: index, start: start, header: item.segments.Header, stable: item.segments.Stable, tail: item.segments.Tail} + m.totalHeight = start + item.height + if wasAtBottom && !m.userHasScrolled { + m.scrollOffset = max(0, m.totalScrollableHeight()-m.height) + } else { + m.scrollOffset = min(m.scrollOffset, max(0, m.totalScrollableHeight()-m.height)) + } + m.scrollview.SetScrollOffset(m.scrollOffset) + m.hoveredURL = nil + m.urlSpans.clear() + return true + } + if start < 0 || end < start || end > len(m.renderedLines) { + m.renderDirty = true + return false + } + // A fallback/full rendering replaces any segmented suffix. + if m.activeSegments != nil && m.activeSegments.index == index { + prefix := make([]string, 0, len(m.renderedLines)+m.activeSegments.height()) + prefix = append(prefix, m.renderedLines...) + prefix = append(prefix, m.activeSegments.header...) + prefix = append(prefix, m.activeSegments.stable...) + prefix = append(prefix, m.activeSegments.tail...) + m.renderedLines = prefix + m.activeSegments = nil + end = len(m.renderedLines) + } + // Every non-virtual item is flattened through the same line source used by a + // full rebuild. RenderedSegments is available for historical assistants too; + // splicing item.lines directly would therefore replace that message with zero + // lines on hover and leave offsets/totalHeight pointing into blank space. + itemLines := m.renderedItemLines(item) + // Replace the final item in place. It is normally the transcript suffix, so + // reslicing avoids copying the entire historical prefix on every streamed + // chunk; append only copies if the tail outgrows retained capacity. + if index == len(m.views)-1 && end == len(m.renderedLines) { + m.renderedLines = append(m.renderedLines[:start], itemLines...) + } else { + replacement := make([]string, 0, len(m.renderedLines)-(end-start)+item.height) + replacement = append(replacement, m.renderedLines[:start]...) + replacement = append(replacement, itemLines...) + replacement = append(replacement, m.renderedLines[end:]...) + m.renderedLines = replacement + } + delta := item.height - (end - start) + for i := index + 1; i < len(m.lineOffsets); i++ { + m.lineOffsets[i] += delta + } + m.totalHeight += delta + if wasAtBottom && !m.userHasScrolled { + m.scrollOffset = max(0, m.totalScrollableHeight()-m.height) + } else { + m.scrollOffset = min(m.scrollOffset, max(0, m.totalScrollableHeight()-m.height)) + } + m.scrollview.SetScrollOffset(m.scrollOffset) + m.hoveredURL = nil + m.urlSpans.clear() + return true +} + +func (m *model) refreshHoverItems(indices ...int) { + for _, index := range indices { + if index >= 0 { + m.refreshRenderedItem(index) + } + } +} + func (m *model) invalidateItem(index int) { // Delete unconditionally: cacheability is state-dependent (e.g. a settled // reasoning block becomes animated again), so gating the delete on @@ -1730,8 +2026,11 @@ func (m *model) AddToolResult(msg *runtime.ToolCallResponseEvent, status types.T func (m *model) AppendToLastMessage(agentName, content string) tea.Cmd { m.removeSpinner() + // The first assistant chunk replaces the pending-response spinner. After + // removal the transcript can legitimately be empty; create the streaming + // message rather than dropping the first and every later chunk. if len(m.messages) == 0 { - return nil + return m.addMessage(types.Agent(types.MessageTypeAssistant, agentName, content)) } lastIdx := len(m.messages) - 1 @@ -1739,10 +2038,25 @@ func (m *model) AppendToLastMessage(agentName, content string) tea.Cmd { // Append to existing assistant message from same agent if lastMsg.Type == types.MessageTypeAssistant && lastMsg.Sender == agentName { - lastMsg.Content += content - cmd := m.views[lastIdx].(message.Model).SetMessage(lastMsg) - m.invalidateItem(lastIdx) - return cmd + if m.userHasScrolled { + if len(m.deferredTail) == 0 { + m.deferredTailIndex = lastIdx + } + m.deferredTail = append(m.deferredTail, content) + return nil + } + materializeCmd := m.materializeDeferredTail() + cmd := m.views[lastIdx].(message.Model).AppendContent(content) + // While scrolled away from the tail, the viewport and its geometry are + // unchanged. Retain the exact content but defer markdown rendering and + // transcript splicing until the tail becomes visible again. + if m.userHasScrolled { + m.renderedItems.Delete(lastIdx) + return tea.Batch(materializeCmd, cmd) + } + m.refreshRenderedItem(lastIdx) + m.visualGeneration++ + return tea.Batch(materializeCmd, cmd) } return m.addMessage(types.Agent(types.MessageTypeAssistant, agentName, content)) @@ -1987,11 +2301,19 @@ func (m *model) labelHit(msgIdx, localLine, col int, label string) bool { } item := m.renderItem(msgIdx, m.views[msgIdx]) - if localLine < 0 || localLine >= len(item.lines) { + var lines []string + if item.segments != nil { + lines = append(lines, item.segments.Header...) + lines = append(lines, item.segments.Stable...) + lines = append(lines, item.segments.Tail...) + } else { + lines = item.lines + } + if localLine < 0 || localLine >= len(lines) { return false } - plainLine := ansi.Strip(item.lines[localLine]) + plainLine := ansi.Strip(lines[localLine]) before, _, ok := strings.Cut(plainLine, label) if !ok { return false @@ -2025,6 +2347,17 @@ func (m *model) isEditLabelClick(msgIdx, localLine, col int) bool { return m.labelHit(msgIdx, localLine, col, types.UserMessageEditLabel) } +func (m *model) renderedItemLines(item renderedItem) []string { + if item.segments == nil { + return item.lines + } + lines := make([]string, 0, item.height) + lines = append(lines, item.segments.Header...) + lines = append(lines, item.segments.Stable...) + lines = append(lines, item.segments.Tail...) + return lines +} + // codeBlockAt returns the raw code of the fenced code block whose copy label // is at the given click position, if any. func (m *model) codeBlockAt(msgIdx, localLine, col int) (string, bool) { @@ -2054,10 +2387,11 @@ func (m *model) codeBlockAt(msgIdx, localLine, col int) (string, bool) { } item := m.renderItem(msgIdx, m.views[msgIdx]) - if localLine < 0 || localLine >= len(item.lines) { + lines := m.renderedItemLines(item) + if localLine < 0 || localLine >= len(lines) { return "", false } - plainLine := ansi.Strip(item.lines[localLine]) + plainLine := ansi.Strip(lines[localLine]) before, _, found := strings.Cut(plainLine, markdown.CodeBlockCopyIcon) if !found { return "", false @@ -2142,6 +2476,12 @@ func (m *model) IsMouseOnScrollbar(x, y int) bool { } func (m *model) handleScrollviewUpdate(msg tea.Msg) (layout.Model, tea.Cmd) { + // Drag calculations depend on total height and may jump directly into the + // stale final item, so reconcile before delegating any active drag update. + var materializeCmd tea.Cmd + if m.scrollview.IsDragging() { + materializeCmd = m.materializeDeferredTailForInteraction() + } _, cmd := m.scrollview.UpdateMouse(msg) m.scrollOffset = m.scrollview.ScrollOffset() if m.isAtBottom() { @@ -2150,7 +2490,7 @@ func (m *model) handleScrollviewUpdate(msg tea.Msg) (layout.Model, tea.Cmd) { m.userHasScrolled = true m.bottomSlack = 0 } - return m, cmd + return m, tea.Batch(materializeCmd, cmd) } // hasAnimatedContent returns true if the message list contains content that diff --git a/pkg/tui/components/messages/messages_test.go b/pkg/tui/components/messages/messages_test.go index a3cc7c95cc..80661ea18d 100644 --- a/pkg/tui/components/messages/messages_test.go +++ b/pkg/tui/components/messages/messages_test.go @@ -743,7 +743,7 @@ func TestRenderCacheInvalidatesOnAnimationTickWithAnimatedContent(t *testing.T) // An animation tick must refresh the cache so the spinner frame advances. // onAnimationTick now re-renders eagerly inside Update, so the resulting // View() output stays consistent with the latest tick. - m.Update(animation.TickMsg{Frame: 1}) + m.Update(animation.TickMsg{}) require.NotEmpty(t, m.renderedLines) require.Contains(t, m.View(), "running_tool") @@ -773,7 +773,7 @@ func TestRenderCacheNotInvalidatedOnAnimationTickWithoutAnimatedContent(t *testi m.renderDirty = false // Send animation tick - should NOT invalidate cache because no animated content - m.Update(animation.TickMsg{Frame: 1}) + m.Update(animation.TickMsg{}) // Cache should still be clean (not dirty) assert.False(t, m.renderDirty, "renderDirty should remain false after animation tick without animated content") @@ -1689,6 +1689,18 @@ func TestAddAgentReturnWithoutSpinnerAppends(t *testing.T) { assert.Len(t, m.messages, 2, "empty agent names add nothing") } +func TestAppendFirstAssistantChunkAfterSpinnerRemoval(t *testing.T) { + t.Parallel() + + m := NewScrollableView(animation.NewRuntime(), 80, 24, &service.SessionState{}).(*model) + m.addMessage(types.Agent(types.MessageTypeSpinner, "root", "working")) + m.AppendToLastMessage("root", "first chunk") + + require.Len(t, m.messages, 1) + assert.Equal(t, types.MessageTypeAssistant, m.messages[0].Type) + assert.Equal(t, "first chunk", m.messages[0].Content) +} + // TestAgentReturnIsInertInList verifies the transition is neither selectable // nor hoverable-for-copy nor animated: it must not be treated like assistant // or tool content by the list machinery. @@ -1709,3 +1721,28 @@ func TestAgentReturnIsInertInList(t *testing.T) { assert.Contains(t, out, types.AgentReturnLabel) assert.NotContains(t, out, types.MessageCopyLabel) } + +func TestMessageCacheBoundsHistoricalRerender(t *testing.T) { + runtime := animation.NewRuntime() + m := NewScrollableView(runtime, 120, 40, &service.SessionState{}).(*model) + sess := &session.Session{ID: "work"} + body := strings.Repeat("word ", 1000) + for i := range 1000 { + role := chat.MessageRoleUser + if i%2 == 1 { + role = chat.MessageRoleAssistant + } + sess.Messages = append(sess.Messages, session.NewMessageItem(&session.Message{AgentName: "root", Message: chat.Message{Role: role, Content: body}})) + } + _ = m.LoadFromSession(sess) + _ = m.View() + require.False(t, m.renderDirty) + before := m.renderedItems.Len() + _, _ = m.Update(animation.TickMsg{}) + _ = m.View() + require.Equal(t, before, m.renderedItems.Len(), "unchanged tick preserves bounded cache") + _ = m.AppendToLastMessage("root", " small") + _ = m.View() + require.False(t, m.renderDirty) + require.Equal(t, before, m.renderedItems.Len(), "single append does not trigger history-wide cache growth") +} diff --git a/pkg/tui/components/messages/segmented_stream_test.go b/pkg/tui/components/messages/segmented_stream_test.go new file mode 100644 index 0000000000..826c04edef --- /dev/null +++ b/pkg/tui/components/messages/segmented_stream_test.go @@ -0,0 +1,45 @@ +package messages + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/animation" + "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/types" +) + +func TestVisibleStreamRetainsStableLinesWithoutTranscriptCopies(t *testing.T) { + m := NewScrollableView(animation.NewRuntime(), 80, 12, &service.SessionState{}).(*model) + m.SetSize(80, 12) + msg := types.Agent(types.MessageTypeAssistant, "root", "start ") + m.messages = append(m.messages, msg) + m.views = append(m.views, m.createMessageView(msg)) + _ = m.View() + + for i := range 200 { + chunk := fmt.Sprintf("chunk-%03d **bold** ", i) + if i%8 == 7 { + chunk += "\n\n" + } + m.AppendToLastMessage("root", chunk) + _ = m.View() + } + require.NotNil(t, m.activeSegments) + require.Greater(t, len(m.activeSegments.stable), 20) + + stableLen := len(m.activeSegments.stable) + for i := range 100 { + chunk := fmt.Sprintf("late-%03d `code` ", i) + if i%8 == 7 { + chunk += "\n\n" + } + m.AppendToLastMessage("root", chunk) + _ = m.View() + } + require.NotNil(t, m.activeSegments) + require.GreaterOrEqual(t, len(m.activeSegments.stable), stableLen) + require.LessOrEqual(t, len(m.activeSegments.tail), 32, "mutable suffix remains structurally bounded") +} diff --git a/pkg/tui/components/messages/selection.go b/pkg/tui/components/messages/selection.go index 09d2077f0b..6084f6e92f 100644 --- a/pkg/tui/components/messages/selection.go +++ b/pkg/tui/components/messages/selection.go @@ -161,6 +161,7 @@ type DebouncedCopyMsg struct { func (m *model) autoScroll() tea.Cmd { const scrollThreshold = 2 direction := 0 + var scrollCmd tea.Cmd // Use stored screen Y coordinate to check if mouse is in autoscroll region // mouseToLineCol subtracts 2 for header, so viewport-relative Y is mouseY - 2 @@ -179,7 +180,7 @@ func (m *model) autoScroll() tea.Cmd { maxScrollOffset := max(0, m.totalHeight-m.height) if m.scrollOffset < maxScrollOffset { direction = 1 - m.scrollDown() + scrollCmd = m.scrollDown() // Update endLine to reflect new scroll position m.selection.endLine++ } @@ -189,21 +190,20 @@ func (m *model) autoScroll() tea.Cmd { return nil } - return tea.Tick(20*time.Millisecond, func(time.Time) tea.Msg { + return tea.Batch(scrollCmd, tea.Tick(20*time.Millisecond, func(time.Time) tea.Msg { return AutoScrollTickMsg{Direction: direction} - }) + })) } // selectWordAt selects the word at the given line and column position. // It reports whether a word was actually selected. func (m *model) selectWordAt(line, col int) bool { m.ensureAllItemsRendered() - lines := m.renderedLines - if line < 0 || line >= len(lines) { + if line < 0 || line >= m.totalHeight { return false } - originalLine := lines[line] + originalLine := m.renderedLine(line) plainLine := stripBorderChars(ansi.Strip(originalLine)) if plainLine == "" { return false @@ -250,12 +250,11 @@ func (m *model) selectWordAt(line, col int) bool { // It reports whether a non-blank line was actually selected. func (m *model) selectLineAt(line int) bool { m.ensureAllItemsRendered() - lines := m.renderedLines - if line < 0 || line >= len(lines) { + if line < 0 || line >= m.totalHeight { return false } - originalLine := lines[line] + originalLine := m.renderedLine(line) plainLine := ansi.Strip(originalLine) trimmedLine := strings.TrimSpace(plainLine) if trimmedLine == "" { diff --git a/pkg/tui/components/messages/selection_test.go b/pkg/tui/components/messages/selection_test.go index bea67c047b..4432a9beb6 100644 --- a/pkg/tui/components/messages/selection_test.go +++ b/pkg/tui/components/messages/selection_test.go @@ -270,7 +270,8 @@ func TestClickOnCopyLabelFlashesCopied(t *testing.T) { var line, col int found := false - for i, rendered := range m.renderedLines { + for i := range m.totalHeight { + rendered := m.renderedLine(i) plain := ansi.Strip(rendered) if before, _, ok := strings.Cut(plain, types.MessageCopyLabel); ok { line = i diff --git a/pkg/tui/components/messages/stream_cancel_boundary_test.go b/pkg/tui/components/messages/stream_cancel_boundary_test.go new file mode 100644 index 0000000000..0fa7f08299 --- /dev/null +++ b/pkg/tui/components/messages/stream_cancel_boundary_test.go @@ -0,0 +1,88 @@ +package messages + +import ( + "strings" + "testing" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/animation" + messagecomponent "github.com/docker/docker-agent/pkg/tui/components/message" + msgtypes "github.com/docker/docker-agent/pkg/tui/messages" + "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/types" +) + +func TestFirstAssistantChunkAdvancesVisualGeneration(t *testing.T) { + m := NewScrollableView(animation.NewRuntime(), 60, 8, &service.SessionState{}).(*model) + m.AddAssistantMessage("root", "") + _ = m.View() + before := m.VisualGeneration() + m.AppendToLastMessage("root", "first chunk") + require.Greater(t, m.VisualGeneration(), before, + "replacing a spinner with the first chunk must invalidate a pointer-restorable root cache") +} + +func TestCancelledStreamEveryUTF8MarkdownBoundaryMatchesOneShot(t *testing.T) { + content := "Thinking λ界\n\n- item `inline`\n- [link](https://example.com)\n\n```go\nfmt.Println(\"界\")\n```\n" + for end := 0; end <= len(content); end++ { + if end == 0 || !utf8.ValidString(content[:end]) { + continue + } + t.Run(strings.ReplaceAll(content[max(0, end-8):end], "/", "_"), func(t *testing.T) { + prefix := content[:end] + m := NewScrollableView(animation.NewRuntime(), 64, 7, &service.SessionState{}).(*model) + m.AddAssistantMessage("root", "") + for _, chunk := range splitCancellationChunks(prefix) { + m.AppendToLastMessage("root", chunk) + _ = m.View() + } + m.scrollToTop() + _, _ = m.Update(msgtypes.StreamCancelledMsg{}) + m.StopAnimations() + m.FinalizeStream() + m.scrollToBottom() + beforeClick := m.View() + beforeTotal, beforeOffset := m.totalHeight, m.scrollOffset + beforeContent := m.messages[0].Content + beforeRendered := m.views[len(m.views)-1].View() + beforeBlocks := m.views[len(m.views)-1].(messagecomponent.Model).CodeBlocks() + + expected := messagecomponent.New(animation.NewRuntime(), types.Agent(types.MessageTypeAssistant, "root", prefix), nil) + expected.SetSize(m.contentWidth(), 0) + expectedRendered := expected.View() + expectedBlocks := expected.CodeBlocks() + require.Equal(t, prefix, beforeContent, "cancel must preserve exact source") + require.Equal(t, expectedRendered, beforeRendered, "cancelled segmented output must equal one-shot") + require.Equal(t, expectedBlocks, beforeBlocks, "cancelled code-block metadata must equal one-shot") + require.NotEmpty(t, beforeClick) + + // Mandatory inert click: correct output must already exist, and the + // click must not repair or perturb content/geometry. + _, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseRight, X: 1, Y: 1}) + afterClick := m.View() + require.Equal(t, beforeClick, afterClick) + require.Equal(t, beforeTotal, m.totalHeight) + require.Equal(t, beforeOffset, m.scrollOffset) + require.Equal(t, beforeContent, m.messages[0].Content) + }) + } +} + +func splitCancellationChunks(s string) []string { + if s == "" { + return nil + } + var chunks []string + for s != "" { + n := min(3, len(s)) + for n < len(s) && !utf8.ValidString(s[:n]) { + n++ + } + chunks = append(chunks, s[:n]) + s = s[n:] + } + return chunks +} diff --git a/pkg/tui/components/messages/transcript_representation_regression_test.go b/pkg/tui/components/messages/transcript_representation_regression_test.go new file mode 100644 index 0000000000..c540b5f53e --- /dev/null +++ b/pkg/tui/components/messages/transcript_representation_regression_test.go @@ -0,0 +1,104 @@ +package messages + +import ( + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/animation" + "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/types" +) + +type transcriptInvariant struct { + total, offset, max int + userHasScrolled bool + activeStart, activeEnd int + deferredIndex, deferredN int +} + +func captureTranscriptInvariant(m *model) transcriptInvariant { + activeStart, activeEnd := -1, -1 + if m.activeSegments != nil { + activeStart = m.activeSegments.start + activeEnd = activeStart + m.activeSegments.height() + } + return transcriptInvariant{ + total: m.totalHeight, offset: m.scrollOffset, max: max(0, m.totalScrollableHeight()-m.height), + userHasScrolled: m.userHasScrolled, activeStart: activeStart, activeEnd: activeEnd, + deferredIndex: m.deferredTailIndex, deferredN: len(m.deferredTail), + } +} + +func assertTranscriptExistsAndIsCanonical(t *testing.T, m *model) { + t.Helper() + frame := m.View() + if m.totalHeight > 0 && m.scrollOffset < m.totalHeight { + require.NotEmpty(t, strings.TrimSpace(ansi.Strip(frame)), "nonempty in-range transcript produced an empty viewport") + } + if s := m.activeSegments; s != nil { + require.Len(t, m.renderedLines, s.start, "active suffix must have exactly one owner; flattened prefix ends at its boundary") + require.Equal(t, m.totalHeight, s.start+s.height(), "active suffix must end at total height") + } else { + require.Len(t, m.renderedLines, m.totalHeight, "flattened transcript must own every content line") + } +} + +func TestScrollHoverAlternationPreservesTranscriptRepresentation(t *testing.T) { + for _, tc := range []struct { + name string + setup func(*model) + }{ + {"idle-flattened", func(m *model) { + m.focused, m.selectedMessageIndex = true, len(m.messages)-1 + m.invalidateItem(len(m.messages) - 1) + _ = m.View() + }}, + {"active-virtual-suffix", func(m *model) {}}, + {"scrolled-deferred-tail", func(m *model) { + m.scrollPageUp() + for i := range 20 { + m.AppendToLastMessage("root", fmt.Sprintf("deferred-%02d **bold** λ界\n\n", i)) + } + }}, + } { + t.Run(tc.name, func(t *testing.T) { + m := NewScrollableView(animation.NewRuntime(), 72, 10, &service.SessionState{}).(*model) + m.SetSize(72, 10) + for i := range 3 { + msg := types.Agent(types.MessageTypeAssistant, "root", fmt.Sprintf("history-%d\n\n%s", i, strings.Repeat("long transcript line\n\n", 60))) + m.messages = append(m.messages, msg) + m.views = append(m.views, m.createMessageView(msg)) + } + _ = m.View() + m.scrollToBottom() + tc.setup(m) + assertTranscriptExistsAndIsCanonical(t, m) + + // Deselecting makes the final assistant eligible for segmentation again. + // The following real mouse transitions used to leave both a flattened and + // virtual owner for the same suffix, allowing later cached frames to use + // incompatible line geometry. + m.focused, m.selectedMessageIndex = false, -1 + m.refreshRenderedItem(len(m.messages) - 1) + assertTranscriptExistsAndIsCanonical(t, m) + + for _, delta := range []int{-1, -1_000_000, 1, 1_000_000, -3, 3, -3, 3} { + m.scrollByWheel(delta) + before := captureTranscriptInvariant(m) + for _, motion := range []tea.MouseMotionMsg{ + {X: 20, Y: 1}, {X: 20, Y: m.height + 3}, {X: 20, Y: 1}, {X: 20, Y: m.height + 3}, + } { + _, _ = m.handleMouseMotion(motion) + assertTranscriptExistsAndIsCanonical(t, m) + after := captureTranscriptInvariant(m) + require.Equal(t, before, after, "hover/leave changed transcript geometry, follow state, boundaries, or materialization") + } + } + }) + } +} diff --git a/pkg/tui/components/messages/urldetect.go b/pkg/tui/components/messages/urldetect.go index 16cdc9f1e5..47ed777a24 100644 --- a/pkg/tui/components/messages/urldetect.go +++ b/pkg/tui/components/messages/urldetect.go @@ -332,10 +332,11 @@ func balanceParens(url string) string { // urlAt returns the URL at the given global line and display column, or empty string. func (m *model) urlAt(line, col int) string { m.ensureAllItemsRendered() - if line < 0 || line >= len(m.renderedLines) { + if line < 0 || line >= m.totalHeight { return "" } - for _, span := range m.urlSpans.get(line, m.renderedLines[line]) { + rendered := m.renderedLine(line) + for _, span := range m.urlSpans.get(line, rendered) { if col >= span.startCol && col < span.endCol { return span.url } @@ -347,8 +348,9 @@ func (m *model) urlAt(line, col int) string { func (m *model) updateHoveredURL(line, col int) { m.ensureAllItemsRendered() - if line >= 0 && line < len(m.renderedLines) { - for _, span := range m.urlSpans.get(line, m.renderedLines[line]) { + if line >= 0 && line < m.totalHeight { + rendered := m.renderedLine(line) + for _, span := range m.urlSpans.get(line, rendered) { if col >= span.startCol && col < span.endCol { newHover := &hoveredURL{line: line, startCol: span.startCol, endCol: span.endCol} if m.hoveredURL == nil || *m.hoveredURL != *newHover { diff --git a/pkg/tui/components/reasoningblock/reasoningblock_test.go b/pkg/tui/components/reasoningblock/reasoningblock_test.go index 5711adf256..1fa3f3b838 100644 --- a/pkg/tui/components/reasoningblock/reasoningblock_test.go +++ b/pkg/tui/components/reasoningblock/reasoningblock_test.go @@ -513,7 +513,7 @@ func TestReasoningBlockCompletedToolGracePeriod(t *testing.T) { fakeNow = fakeNow.Add(totalDuration + time.Second) // Send a tick to update fade progress (this is what happens in production) - block.Update(animation.TickMsg{Frame: 1}) + block.Update(animation.TickMsg{}) // Now the tool should be hidden view = block.View() @@ -559,7 +559,7 @@ func TestReasoningBlockFadingState(t *testing.T) { fakeNow = fadeStartTime.Add(time.Millisecond) // Send animation tick to compute fade progress based on elapsed time - block.Update(animation.TickMsg{Frame: 1}) + block.Update(animation.TickMsg{}) assert.Greater(t, block.GetToolFadeProgress("call-1"), 0.0, "Tool should have non-zero fade progress just after fade starts") // Capture view after fading started @@ -588,7 +588,7 @@ func TestReasoningBlockFadingState(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { fakeNow = fadeStartTime.Add(tc.elapsed) - block.Update(animation.TickMsg{Frame: 99}) // Frame number doesn't matter for time-based fade + block.Update(animation.TickMsg{}) // Frame number doesn't matter for time-based fade assert.InDelta(t, tc.expectedProgress, block.GetToolFadeProgress("call-1"), 0.001, "Fade progress should be %v at %v elapsed", tc.expectedProgress, tc.elapsed) }) @@ -668,16 +668,16 @@ func TestReasoningBlockNeedsTick(t *testing.T) { // During visible period - still needs tick fakeNow = completionTime.Add(completedToolVisibleDuration / 2) - block.Update(animation.TickMsg{Frame: 1}) + block.Update(animation.TickMsg{}) assert.True(t, block.NeedsTick(), "Block should need tick during visible period") // During fade period - still needs tick fakeNow = completionTime.Add(completedToolVisibleDuration + completedToolFadeDuration/2) - block.Update(animation.TickMsg{Frame: 2}) + block.Update(animation.TickMsg{}) assert.True(t, block.NeedsTick(), "Block should need tick during fade period") // After grace period ends - no longer needs tick fakeNow = completionTime.Add(completedToolVisibleDuration + completedToolFadeDuration + time.Second) - block.Update(animation.TickMsg{Frame: 3}) + block.Update(animation.TickMsg{}) assert.False(t, block.NeedsTick(), "Block should not need tick after grace period ends") } diff --git a/pkg/tui/components/scrollview/scrollview.go b/pkg/tui/components/scrollview/scrollview.go index 8ac03888c7..240413084d 100644 --- a/pkg/tui/components/scrollview/scrollview.go +++ b/pkg/tui/components/scrollview/scrollview.go @@ -296,6 +296,57 @@ func (m *Model) ViewWithLines(visibleLines []string) string { return m.viewWithLines(visibleLines, -1) } +// ViewWithPaddedLines renders pre-sliced lines that are already exactly +// ContentWidth columns wide. It skips ANSI/grapheme measurement and wrapping; +// callers must uphold the width contract. +func (m *Model) ViewWithPaddedLines(visibleLines []string) string { + if m.width <= 0 || m.height <= 0 { + return "" + } + m.syncScrollbar() + if m.NeedsScrollbar() && len(visibleLines) < m.height { + result := make([]string, m.height) + copy(result, visibleLines) + visibleLines = result + } + return m.composePadded(visibleLines) +} + +func (m *Model) composePadded(lines []string) string { + switch { + case m.NeedsScrollbar(): + sbLines := m.sb.ViewLines() + gap := strings.Repeat(" ", m.gapWidth) + var b strings.Builder + for i, line := range lines { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(line) + b.WriteString(gap) + if i < len(sbLines) { + b.WriteString(sbLines[i]) + } else { + b.WriteString(strings.Repeat(" ", scrollbar.Width)) + } + } + return b.String() + case m.reserveScrollbarSpace: + blank := strings.Repeat(" ", m.gapWidth+scrollbar.Width) + var b strings.Builder + for i, line := range lines { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(line) + b.WriteString(blank) + } + return b.String() + default: + return strings.Join(lines, "\n") + } +} + // ViewWithRestyledLines is like [Model.ViewWithLines] for callers whose // visibleLines are sliced from the content set via [Model.SetContent] at the // current scroll offset (possibly restyled, e.g. selection or hover diff --git a/pkg/tui/components/scrollview/scrollview_test.go b/pkg/tui/components/scrollview/scrollview_test.go index dca843d41d..f0c51feb2f 100644 --- a/pkg/tui/components/scrollview/scrollview_test.go +++ b/pkg/tui/components/scrollview/scrollview_test.go @@ -153,3 +153,16 @@ func TestComposeRemeasuresRestyledLines(t *testing.T) { assert.Equal(t, m.width, ansi.StringWidth(line)) } } + +func TestViewWithPaddedLinesReservesScrollbarSpaceForShortContent(t *testing.T) { + m := New(WithReserveScrollbarSpace(true)) + m.SetSize(20, 5) + m.SetContent([]string{"short"}, 1) + line := "short" + strings.Repeat(" ", m.ContentWidth()-len("short")) + + got := m.ViewWithPaddedLines([]string{line}) + lines := strings.Split(got, "\n") + require.NotEmpty(t, lines) + require.Equal(t, 20, ansi.StringWidth(lines[0])) + require.True(t, strings.HasSuffix(lines[0], strings.Repeat(" ", m.ReservedCols()))) +} diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index 1b35dae7a8..7c7d86b2ac 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -78,7 +78,6 @@ type Model interface { // SetMirroredPadding swaps the horizontal edge padding so the sidebar hugs // the terminal edge when rendered on the left of the chat. SetMirroredPadding(mirrored bool) - VisualGeneration() uint64 SetAgentInfo(agentName, model, description string, contextLimit int64, compactionModel string, primaryContextLimit int64) tea.Cmd SetTeamInfo(availableAgents []runtime.AgentDetails) // SetAgentSwitching records the start (switching=true) or end of a @@ -154,6 +153,8 @@ type Model interface { SetTitleRegenerating(regenerating bool) tea.Cmd // IsScrollbarDragging returns true when the scrollbar thumb is being dragged. IsScrollbarDragging() bool + // VisualGeneration increments whenever an update changes rendered sidebar state. + VisualGeneration() uint64 // WorkingDirectory returns the working directory path displayed in the sidebar. WorkingDirectory() string } @@ -349,6 +350,7 @@ type model struct { cachedNeedsScrollbar bool // Whether scrollbar is needed for cached render cacheDirty bool // True when cache needs rebuild layoutDirty bool // True when a change may alter line count/scrollbar visibility (not just an animation frame) + visualGeneration uint64 // Agent click zones: maps content line index to agent name for click detection agentClickZones map[int]string // content line -> agent name @@ -446,11 +448,10 @@ func (m *model) stopSpinner() { // on the next View(). Use this for changes that may alter the rendered content // AND its line layout (todos, sizing, agents, theme, …): the next View() // re-probes scrollbar visibility via the two-pass render. -func (m *model) VisualGeneration() uint64 { return 0 } - func (m *model) invalidateCache() { m.cacheDirty = true m.layoutDirty = true + m.visualGeneration++ } // invalidateAnimation marks the cache dirty for an animation-only change, i.e. a @@ -460,6 +461,7 @@ func (m *model) invalidateCache() { // the sections only once. func (m *model) invalidateAnimation() { m.cacheDirty = true + m.visualGeneration++ } func (m *model) SetTokenUsage(event *runtime.TokenUsageEvent) { @@ -1175,7 +1177,12 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return m, cmd case tea.MouseClickMsg, tea.MouseMotionMsg, tea.MouseReleaseMsg, messages.WheelCoalescedMsg: if m.mode == ModeVertical { + beforeOffset := m.scrollview.ScrollOffset() + beforeDragging := m.scrollview.IsDragging() _, cmd := m.scrollview.Update(msg) + if m.scrollview.ScrollOffset() != beforeOffset || m.scrollview.IsDragging() != beforeDragging { + m.visualGeneration++ + } return m, cmd } return m, nil @@ -1429,6 +1436,8 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { } } +func (m *model) VisualGeneration() uint64 { return m.visualGeneration } + // View renders the component func (m *model) View() string { var content string diff --git a/pkg/tui/image/image.go b/pkg/tui/image/image.go index b3748c61be..dbf0772bb0 100644 --- a/pkg/tui/image/image.go +++ b/pkg/tui/image/image.go @@ -110,6 +110,11 @@ type MarkdownReference struct { // MarkdownReferences extracts image references in document order. func MarkdownReferences(markdown string) []MarkdownReference { + // Plain text is overwhelmingly common during progressive streaming; avoid + // constructing a Goldmark AST until an image opener is even possible. + if !strings.Contains(markdown, "![") { + return nil + } source := []byte(markdown) document := goldmark.DefaultParser().Parse(text.NewReader(source)) var refs []MarkdownReference diff --git a/pkg/tui/page/chat/runtime_events.go b/pkg/tui/page/chat/runtime_events.go index 1b07ea65f0..a6682714ca 100644 --- a/pkg/tui/page/chat/runtime_events.go +++ b/pkg/tui/page/chat/runtime_events.go @@ -377,7 +377,10 @@ func (p *chatPage) handleStreamStopped(msg *runtime.StreamStoppedEvent) tea.Cmd return tea.Batch(p.messages.ScrollToBottom(), sidebarCmd, p.setPendingResponse(true)) } - // Outermost stream stopped — fully clean up. + // Outermost stream stopped — fully clean up. This is the exact-content + // boundary for the active root response; nested stops leave the parent's + // deferred tail intact until the parent itself stops or the user re-enters it. + finalizeCmd := p.messages.FinalizeStream() // Only play the success sound when the stream completed normally. // Errors already trigger a failure sound via ErrorEvent, and // user-initiated cancels don't warrant a chime. @@ -402,7 +405,7 @@ func (p *chatPage) handleStreamStopped(msg *runtime.StreamStoppedEvent) tea.Cmd }) } - return tea.Batch(p.messages.ScrollToBottom(), spinnerCmd, sidebarCmd, queueCmd, exitCmd) + return tea.Batch(finalizeCmd, p.messages.ScrollToBottom(), spinnerCmd, sidebarCmd, queueCmd, exitCmd) } // handlePartialToolCall processes partial tool call events by rendering each diff --git a/pkg/tui/page/chat/stream_depth_regression_test.go b/pkg/tui/page/chat/stream_depth_regression_test.go new file mode 100644 index 0000000000..27e4885eea --- /dev/null +++ b/pkg/tui/page/chat/stream_depth_regression_test.go @@ -0,0 +1,53 @@ +package chat + +import ( + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/app" + "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/tui/animation" + "github.com/docker/docker-agent/pkg/tui/service" +) + +func TestNestedStreamStopDoesNotFinalizeScrolledUpParentTail(t *testing.T) { + sess := session.New() + p := New(animation.NewRuntime(), t.Context(), app.New(t.Context(), queueTestRuntime{}, sess), service.NewSessionState(sess)).(*chatPage) + p.messages.SetSize(60, 8) + + _, _ = p.handleRuntimeEvent(runtime.StreamStarted(sess.ID, "root")) + _, _ = p.handleRuntimeEvent(runtime.StreamStarted("child-session", "child")) + require.Equal(t, 2, p.streamDepth) + + p.messages.AddUserMessage(strings.Repeat("history line\n", 40)) + p.messages.AddAssistantMessage("root", "") + p.messages.AppendToLastMessage("root", "parent tail start\n") + _ = p.messages.View() + _, _ = p.messages.Update(tea.KeyPressMsg{Code: tea.KeyHome}) + p.messages.AppendToLastMessage("root", "deferred parent marker\n") + require.Positive(t, deferredTailLen(t, p), "fixture must be scrolled up with a deferred parent tail") + + _, _ = p.handleRuntimeEvent(runtime.StreamStopped("child-session", "child", "normal")) + require.Equal(t, 1, p.streamDepth) + require.Positive(t, deferredTailLen(t, p), "a nested stop must not finalize the still-active parent response") + require.True(t, p.working) + + _, _ = p.handleRuntimeEvent(runtime.StreamStopped(sess.ID, "root", "normal")) + require.Zero(t, p.streamDepth) + require.Zero(t, deferredTailLen(t, p), "the outermost stop is the exact-content finalization boundary") + require.False(t, p.working) +} + +func deferredTailLen(t *testing.T, p *chatPage) int { + t.Helper() + value := reflect.ValueOf(p.messages) + require.Equal(t, reflect.Pointer, value.Kind()) + field := value.Elem().FieldByName("deferredTail") + require.True(t, field.IsValid(), "messages model must retain deferred-tail state") + return field.Len() +} diff --git a/pkg/tui/tui_first_chunk_terminal_test.go b/pkg/tui/tui_first_chunk_terminal_test.go new file mode 100644 index 0000000000..fa0ff2fc55 --- /dev/null +++ b/pkg/tui/tui_first_chunk_terminal_test.go @@ -0,0 +1,39 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/require" + + agentruntime "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/tui/messages" +) + +func TestActualProgramFirstChunkReplacesPrimedSpinnerWithoutClick(t *testing.T) { + root, _, _ := wallClockRoot(t, 120, 40) + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.StreamStarted("profile", "root")}) + model := &streamingMotionModel{root: root, ready: make(chan struct{})} + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(&wallClockCountingWriter{}), tea.WithWindowSize(120, 40)) + done := make(chan error, 1) + go func() { _, err := program.Run(); done <- err }() + <-model.ready + before := programFrame(t, program) + require.NotEmpty(t, strings.TrimSpace(ansi.Strip(before))) + + program.Send(agentruntime.AgentChoice("root", "profile", "FIRST-CHUNK-MARKER\n\n")) + programAck(t, program) + current := programFrame(t, program) + require.Contains(t, ansi.Strip(current), "FIRST-CHUNK-MARKER", "current viewport must render first chunk without recovery input") + + program.Send(tea.MouseClickMsg{Button: tea.MouseLeft, X: 0, Y: 39}) + programAck(t, program) + recovered := programFrame(t, program) + require.Equal(t, ansi.Strip(current), ansi.Strip(recovered), "inert click must not repair the current viewport") + + program.Quit() + require.NoError(t, <-done) + root.ar.Stop() +} diff --git a/pkg/tui/tui_hover_stream_reentry_test.go b/pkg/tui/tui_hover_stream_reentry_test.go new file mode 100644 index 0000000000..a5f50fb32e --- /dev/null +++ b/pkg/tui/tui_hover_stream_reentry_test.go @@ -0,0 +1,154 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/require" + + agentruntime "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/tui/messages" +) + +func TestBlurFocusDoesNotMaterializeStreamTail(t *testing.T) { + root, _, _ := wallClockRoot(t, 120, 40) + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.StreamStarted("profile", "root")}) + chunk := "Paragraph with **markdown**, Unicode λ界, and a [link](https://example.com).\n\n" + for range 100 { + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.AgentChoice("root", "profile", chunk)}) + _ = root.View() + } + _, _ = root.Update(messages.WheelCoalescedMsg{Delta: -3, X: 40, Y: 20}) + for range 20 { + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.AgentChoice("root", "profile", chunk)}) + } + before := root.View().Content + _, _ = root.Update(tea.BlurMsg{}) + _, _ = root.Update(tea.FocusMsg{}) + after := root.View().Content + require.Equal(t, before, after, "focus transitions must not materialize or move the offscreen tail") + root.ar.Stop() +} + +func programFrame(t *testing.T, program *tea.Program) string { + t.Helper() + content := make(chan string) + program.Send(streamingMotionRead{content: content}) + return <-content +} + +func programAck(t *testing.T, program *tea.Program) { + t.Helper() + ack := make(chan struct{}) + program.Send(streamingMotionAck{done: ack}) + <-ack +} + +func TestActualProgramPendingSpinnerHoverIsFrameIsolated(t *testing.T) { + root, _, _ := wallClockRoot(t, 120, 40) + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.StreamStarted("profile", "root")}) + model := &streamingMotionModel{root: root, ready: make(chan struct{})} + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(&wallClockCountingWriter{}), tea.WithWindowSize(120, 40)) + done := make(chan error, 1) + go func() { _, err := program.Run(); done <- err }() + <-model.ready + + baseline := programFrame(t, program) + require.NotEmpty(t, strings.TrimSpace(ansi.Strip(baseline)), "pending spinner frame") + for range 20 { + program.Send(tea.MouseMotionMsg{X: 40, Y: 25}) + programAck(t, program) + hovered := programFrame(t, program) + require.NotEmpty(t, strings.TrimSpace(ansi.Strip(hovered)), "hover produced an intermediate blank frame") + require.Equal(t, rootFrameWidths(baseline), rootFrameWidths(hovered), "same elapsed animation state geometry") + program.Send(tea.MouseMotionMsg{X: 40, Y: 2}) + programAck(t, program) + require.Equal(t, baseline, programFrame(t, program), "leave restores the exact same-elapsed frame") + } + program.Quit() + require.NoError(t, <-done) + root.ar.Stop() +} + +func TestActualProgramVirtualSuffixKeyWheelPageMatrixNeverBlanks(t *testing.T) { + root, _, _ := wallClockRoot(t, 120, 40) + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.StreamStarted("profile", "root")}) + chunk := "stream marker **bold** `code` λ界\n\n" + for range 200 { + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.AgentChoice("root", "profile", chunk)}) + _ = root.View() + } + model := &streamingMotionModel{root: root, ready: make(chan struct{})} + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(&wallClockCountingWriter{}), tea.WithWindowSize(120, 40)) + done := make(chan error, 1) + go func() { _, err := program.Run(); done <- err }() + <-model.ready + + for _, msg := range []tea.Msg{ + messages.WheelCoalescedMsg{Delta: -1_000_000, X: 40, Y: 20}, + tea.KeyPressMsg{Code: tea.KeyPgDown}, + tea.KeyPressMsg{Code: 'G'}, + tea.KeyPressMsg{Code: tea.KeyPgUp}, + messages.WheelCoalescedMsg{Delta: 1_000_000, X: 40, Y: 20}, + } { + program.Send(msg) + programAck(t, program) + frame := programFrame(t, program) + require.NotEmpty(t, strings.TrimSpace(ansi.Strip(frame)), "movement produced a blank root frame: %T", msg) + lineCount := len(strings.Split(frame, "\n")) + require.Contains(t, []int{39, 40}, lineCount, "fixed root height with optional terminal trailing newline after %T", msg) + for _, width := range rootFrameWidths(frame) { + require.Equal(t, 120, width, "fixed root width after %T", msg) + } + } + frame := programFrame(t, program) + require.Contains(t, ansi.Strip(frame), "stream marker", "exact bottom dropped virtual active suffix") + program.Quit() + require.NoError(t, <-done) + root.ar.Stop() +} + +func TestActualProgramHoverThenBottomReentryStaysBounded(t *testing.T) { + root, _, _ := wallClockRoot(t, 120, 40) + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.StreamStarted("profile", "root")}) + chunk := "Paragraph with **markdown**, `code`, Unicode λ界, and a [link](https://example.com).\n\n" + for range 300 { + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.AgentChoice("root", "profile", chunk)}) + _ = root.View() + } + root.chatPage.ScrollToBottom() + before := root.View().Content + model := &streamingMotionModel{root: root, ready: make(chan struct{})} + writer := &wallClockCountingWriter{} + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(writer), tea.WithWindowSize(120, 40)) + done := make(chan error, 1) + go func() { _, err := program.Run(); done <- err }() + <-model.ready + program.Send(tea.MouseMotionMsg{X: 40, Y: 25}) + programAck(t, program) + hovered := programFrame(t, program) + require.Equal(t, rootFrameWidths(before), rootFrameWidths(hovered), "hover preserves viewport geometry") + program.Send(messages.WheelCoalescedMsg{Delta: -3, X: 40, Y: 20}) + deferredChunk := "DEFERRED-REENTRY-MARKER **markdown**\n\n" + for range 100 { + program.Send(agentruntime.AgentChoice("root", "profile", deferredChunk)) + } + programAck(t, program) + scrolled := programFrame(t, program) + require.NotContains(t, ansi.Strip(scrolled), "DEFERRED-REENTRY-MARKER", "offscreen tail remains outside viewport") + beforeWrites := writer.writes.Load() + program.Send(messages.WheelCoalescedMsg{Delta: 1_000_000, X: 40, Y: 20}) + programAck(t, program) + reentry := programFrame(t, program) + require.Contains(t, ansi.Strip(reentry), "DEFERRED-REENTRY-MARKER", "bottom reentry materializes current tail") + for range 20 { + program.Send(agentruntime.AgentChoice("root", "profile", chunk)) + } + programAck(t, program) + require.LessOrEqual(t, writer.writes.Load()-beforeWrites, uint64(30), "follow-tail renderer work remains bounded by events") + program.Quit() + require.NoError(t, <-done) + root.ar.Stop() +} diff --git a/pkg/tui/tui_noop_input_test.go b/pkg/tui/tui_noop_input_test.go index f611181149..a486a08731 100644 --- a/pkg/tui/tui_noop_input_test.go +++ b/pkg/tui/tui_noop_input_test.go @@ -20,7 +20,7 @@ import ( func populateScrollableRoot(t *testing.T) *appModel { t.Helper() - root := wallClockRoot(t, 100, 35) + root, _, _ := wallClockRoot(t, 100, 35) for i := range 30 { msg := session.NewAgentMessage("root", &chat.Message{ Role: chat.MessageRoleAssistant, @@ -69,7 +69,7 @@ func TestStreamChunkRendersBeforeRecoveryClick(t *testing.T) { }}, } { t.Run(tc.name, func(t *testing.T) { - root := wallClockRoot(t, 120, 40) + root, _, _ := wallClockRoot(t, 120, 40) seed := session.NewAgentMessage("root", &chat.Message{Role: chat.MessageRoleAssistant, Content: "first chunk"}) root.application.Session().Messages = append(root.application.Session().Messages, session.NewMessageItem(seed)) _ = root.chatPage.Init() @@ -161,7 +161,7 @@ func TestActualProgramWritesAfterEffectiveIdleInput(t *testing.T) { } func TestNoOpPointerAndWheelReuseRootCache(t *testing.T) { - root := wallClockRoot(t, 120, 40) + root, _, _ := wallClockRoot(t, 120, 40) root.focusedPanel = PanelContent _ = root.chatPage.FocusMessages() _, _ = root.Update(tea.KeyPressMsg{Code: 'g'}) // top boundary diff --git a/pkg/tui/tui_perf_harness_test.go b/pkg/tui/tui_perf_harness_test.go index fd99a599e1..1c04c89beb 100644 --- a/pkg/tui/tui_perf_harness_test.go +++ b/pkg/tui/tui_perf_harness_test.go @@ -1,10 +1,18 @@ +//nolint:unparam // Shared performance harness is activated by descendant benchmark tests. package tui import ( + "fmt" + "runtime" + "strings" + "sync/atomic" "testing" + "time" "github.com/docker/docker-agent/pkg/app" + chatmsg "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/tools" "github.com/docker/docker-agent/pkg/tui/components/spinner" "github.com/docker/docker-agent/pkg/tui/page/chat" "github.com/docker/docker-agent/pkg/tui/service" @@ -12,13 +20,47 @@ import ( "github.com/docker/docker-agent/pkg/tui/styles" ) -func wallClockRoot(tb testing.TB, width, height int) *appModel { +type wallClockCountingWriter struct{ writes, bytes atomic.Uint64 } + +func (w *wallClockCountingWriter) Write(p []byte) (int, error) { + w.writes.Add(1) + w.bytes.Add(uint64(len(p))) + return len(p), nil +} + +func mixedHistorySession(count int) (*session.Session, int, int) { + body := strings.Repeat("word ", 996) + "**bold** `code` λ界 end" // exactly 1,000 words + items := make([]session.Item, 0, count) + totalBytes := 0 + for i := range count { + id := fmt.Sprintf("call-%04d", i) + var msg *session.Message + switch i % 5 { + case 0: + msg = session.UserMessage(body) + case 1: + msg = &session.Message{AgentName: "root", Message: chatmsg.Message{Role: chatmsg.MessageRoleAssistant, Content: "## Assistant\n\n" + body}} + case 2: + msg = &session.Message{AgentName: "root", Message: chatmsg.Message{Role: chatmsg.MessageRoleAssistant, Content: body, ReasoningContent: body}} + case 3: + msg = &session.Message{AgentName: "root", Message: chatmsg.Message{Role: chatmsg.MessageRoleAssistant, Content: body, ReasoningContent: body, ToolCalls: []tools.ToolCall{{ID: id, Function: tools.FunctionCall{Name: "read_file", Arguments: `{"path":"fixture"}`}}}, ToolDefinitions: []tools.Tool{{Name: "read_file", Description: body}}}} + default: + msg = &session.Message{AgentName: "root", Message: chatmsg.Message{Role: chatmsg.MessageRoleTool, ToolCallID: fmt.Sprintf("call-%04d", i-1), Content: body}} + } + items = append(items, session.NewMessageItem(msg)) + totalBytes += len(msg.Message.Content) + len(msg.Message.ReasoningContent) + } + return &session.Session{ID: "profile", Title: "profile", Messages: items}, count * 1000, totalBytes +} + +func wallClockRoot(tb testing.TB, width, height int) (*appModel, time.Duration, runtime.MemStats) { tb.Helper() if setter, ok := tb.(interface{ Setenv(key, value string) }); ok { home := tb.TempDir() setter.Setenv("HOME", home) setter.Setenv("USERPROFILE", home) } + started := time.Now() sess := &session.Session{ID: "profile", Title: "profile"} a := app.New(tb.Context(), stubRuntime{}, sess) m := New(tb.Context(), nil, a, "", func() {}, WithHideSidebar()).(*appModel) @@ -39,5 +81,7 @@ func wallClockRoot(tb testing.TB, width, height int) *appModel { m.handleWindowResize(width, height) _ = m.Init() // synchronously loads the session; returned one-shot commands are warm-up only _ = m.View() - return m + var memory runtime.MemStats + runtime.ReadMemStats(&memory) + return m, time.Since(started), memory } diff --git a/pkg/tui/tui_scroll_cancel_matrix_test.go b/pkg/tui/tui_scroll_cancel_matrix_test.go new file mode 100644 index 0000000000..0476d29857 --- /dev/null +++ b/pkg/tui/tui_scroll_cancel_matrix_test.go @@ -0,0 +1,53 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/require" + + agentruntime "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/tui/messages" +) + +func TestActualProgramScrollCancelResizeMatrixNeverNeedsRecoveryClick(t *testing.T) { + root, _, _ := wallClockRoot(t, 120, 40) + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.StreamStarted("profile", "root")}) + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.AgentChoiceReasoning("root", "profile", "thinking prefix λ界\n\n")}) + chunk := "matrix marker **bold** `code` λ界 [link](https://example.com)\n\n" + for range 120 { + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.AgentChoice("root", "profile", chunk)}) + _ = root.View() + } + model := &streamingMotionModel{root: root, ready: make(chan struct{})} + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(&wallClockCountingWriter{}), tea.WithWindowSize(120, 40)) + done := make(chan error, 1) + go func() { _, err := program.Run(); done <- err }() + <-model.ready + sequence := []tea.Msg{ + messages.WheelCoalescedMsg{Delta: -1_000_000, X: 40, Y: 20}, + tea.MouseMotionMsg{X: 40, Y: 20}, + tea.BlurMsg{}, + tea.FocusMsg{}, + tea.WindowSizeMsg{Width: 1, Height: 1}, + tea.WindowSizeMsg{Width: 120, Height: 40}, + messages.WheelCoalescedMsg{Delta: 4, X: 40, Y: 20}, + messages.StreamCancelledMsg{ShowMessage: true}, + messages.WheelCoalescedMsg{Delta: 1_000_000, X: 40, Y: 20}, + } + for _, msg := range sequence { + program.Send(msg) + programAck(t, program) + current := programFrame(t, program) + require.NotEmpty(t, strings.TrimSpace(ansi.Strip(current)), "current viewport blank after %T", msg) + program.Send(tea.MouseClickMsg{Button: tea.MouseLeft, X: 0, Y: 39}) + programAck(t, program) + require.Equal(t, ansi.Strip(current), ansi.Strip(programFrame(t, program)), "inert click repaired viewport after %T", msg) + } + require.Contains(t, ansi.Strip(programFrame(t, program)), "matrix marker", "bottom viewport retains current stream content") + program.Quit() + require.NoError(t, <-done) + root.ar.Stop() +} diff --git a/pkg/tui/tui_stream_motion_scaling_test.go b/pkg/tui/tui_stream_motion_scaling_test.go new file mode 100644 index 0000000000..7ba24adaca --- /dev/null +++ b/pkg/tui/tui_stream_motion_scaling_test.go @@ -0,0 +1,136 @@ +package tui + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/require" + + agentruntime "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/tui/messages" +) + +type ( + streamingMotionAck struct{ done chan struct{} } + streamingMotionRead struct{ content chan string } + streamingMotionModel struct { + root *appModel + ready chan struct{} + once sync.Once + mu sync.Mutex + chunks, motions atomic.Uint64 + views, compositions atomic.Uint64 + } +) + +func (m *streamingMotionModel) Init() tea.Cmd { return nil } +func (m *streamingMotionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.mu.Lock() + defer m.mu.Unlock() + + switch msg := msg.(type) { + case *agentruntime.AgentChoiceEvent: + m.chunks.Add(1) + case tea.MouseMotionMsg: + m.motions.Add(1) + case streamingMotionAck: + close(msg.done) + return m, nil + case streamingMotionRead: + msg.content <- m.root.View().Content + return m, nil + } + updated, cmd := m.root.Update(msg) + m.root = updated.(*appModel) + return m, cmd +} + +func (m *streamingMotionModel) View() tea.View { + m.mu.Lock() + defer m.mu.Unlock() + + m.views.Add(1) + if !m.root.viewCacheValid { + m.compositions.Add(1) + } + v := m.root.View() + m.once.Do(func() { close(m.ready) }) + return v +} + +func waitForProgramQuiescence(t *testing.T, model *streamingMotionModel, writer *wallClockCountingWriter) { + t.Helper() + lastViews, lastCompositions, lastWrites := model.views.Load(), model.compositions.Load(), writer.writes.Load() + stableSince := time.Now() + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + timeout := time.NewTimer(5 * time.Second) + defer timeout.Stop() + for { + select { + case <-ticker.C: + views, compositions, writes := model.views.Load(), model.compositions.Load(), writer.writes.Load() + if views != lastViews || compositions != lastCompositions || writes != lastWrites { + lastViews, lastCompositions, lastWrites = views, compositions, writes + stableSince = time.Now() + } + if time.Since(stableSince) >= 50*time.Millisecond { + return + } + case <-timeout.C: + t.Fatal("Bubble Tea program did not quiesce") + } + } +} + +func TestActualProgramLongStreamMotionWorkIsViewportBounded(t *testing.T) { + root, _, _ := wallClockRoot(t, 120, 40) + sess, _, _ := mixedHistorySession(1000) + root.application.Session().Messages = sess.Messages + _ = root.chatPage.Init() + root.handleWindowResize(120, 40) + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.StreamStarted("profile", "root")}) + _ = root.View() + model := &streamingMotionModel{root: root, ready: make(chan struct{})} + writer := &wallClockCountingWriter{} + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(writer), tea.WithWindowSize(120, 40)) + done := make(chan error, 1) + go func() { _, err := program.Run(); done <- err }() + <-model.ready + waitForProgramQuiescence(t, model, writer) + + chunk := "Paragraph with **markdown**, `code`, Unicode λ界, and a [link](https://example.com).\n\n" + var earlyViews, earlyCompositions uint64 + for i := range 240 { + program.Send(agentruntime.AgentChoice("root", "profile", chunk)) + program.Send(tea.MouseMotionMsg{X: 50 + i%2, Y: 20}) + if i == 39 || i == 199 { + ack := make(chan struct{}) + program.Send(streamingMotionAck{done: ack}) + <-ack + if i == 39 { + earlyViews, earlyCompositions = model.views.Load(), model.compositions.Load() + } + } + } + ack := make(chan struct{}) + program.Send(streamingMotionAck{done: ack}) + <-ack + waitForProgramQuiescence(t, model, writer) + require.Equal(t, uint64(240), model.chunks.Load()) + require.Equal(t, uint64(240), model.motions.Load()) + lateViews := model.views.Load() - earlyViews + lateCompositions := model.compositions.Load() - earlyCompositions + require.LessOrEqual(t, lateViews, uint64(420), "views remain bounded by 400 late input events") + require.LessOrEqual(t, lateCompositions, uint64(420), "root compositions remain bounded by late input events") + require.Positive(t, writer.writes.Load(), "stream remains writer-visible") + views := model.views.Load() + waitForProgramQuiescence(t, model, writer) + require.Equal(t, views, model.views.Load(), "program quiesces after stream input") + root.ar.Stop() + program.Quit() + require.NoError(t, <-done) +} diff --git a/pkg/tui/tui_stream_scrolledup_test.go b/pkg/tui/tui_stream_scrolledup_test.go new file mode 100644 index 0000000000..ea3ed4412c --- /dev/null +++ b/pkg/tui/tui_stream_scrolledup_test.go @@ -0,0 +1,91 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/require" + + agentruntime "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/tui/messages" +) + +func rootFrameWidths(frame string) []int { + lines := strings.Split(frame, "\n") + widths := make([]int, len(lines)) + for i, line := range lines { + widths[i] = ansi.StringWidth(line) + } + return widths +} + +func TestActualProgramScrolledUpStreamDefersOffscreenTail(t *testing.T) { + root, _, _ := wallClockRoot(t, 120, 40) + sess, _, _ := mixedHistorySession(1000) + root.application.Session().Messages = sess.Messages + _ = root.chatPage.Init() + root.handleWindowResize(120, 40) + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.StreamStarted("profile", "root")}) + _, _ = root.Update(messages.RoutedMsg{SessionID: "profile", Inner: agentruntime.AgentChoice("root", "profile", "start\n\n")}) + _ = root.View() + root.chatPage.ScrollToBottom() + _, _ = root.Update(messages.WheelCoalescedMsg{Delta: -3, X: 30, Y: 15}) + stable := root.View().Content + + model := &streamingMotionModel{root: root, ready: make(chan struct{})} + writer := &wallClockCountingWriter{} + program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(writer), tea.WithWindowSize(120, 40)) + done := make(chan error, 1) + go func() { _, err := program.Run(); done <- err }() + <-model.ready + waitForProgramQuiescence(t, model, writer) + baselineWrites := writer.writes.Load() + baselineCompositions := model.compositions.Load() + + chunk := "offscreen **markdown** [link](https://example.com)\n\n" + for i := range 200 { + program.Send(agentruntime.AgentChoice("root", "profile", chunk)) + program.Send(tea.MouseMotionMsg{X: 30 + i%2, Y: 10}) + } + ack := make(chan struct{}) + program.Send(streamingMotionAck{done: ack}) + <-ack + waitForProgramQuiescence(t, model, writer) + require.Equal(t, uint64(200), model.chunks.Load()) + require.Equal(t, uint64(200), model.motions.Load()) + content := make(chan string) + program.Send(streamingMotionRead{content: content}) + visible := <-content + require.Equal(t, strings.Count(stable, "\n"), strings.Count(visible, "\n"), "offscreen stream and hover must not change viewport line count") + require.Equal(t, rootFrameWidths(stable), rootFrameWidths(visible), "offscreen stream and hover must not change viewport widths/wrapping") + require.LessOrEqual(t, model.compositions.Load()-baselineCompositions, uint64(2)) + require.LessOrEqual(t, writer.writes.Load()-baselineWrites, uint64(2)) + + // Stream stop is an exact-content boundary even while the viewport remains + // scrolled up. The stop's ScrollToBottom command is intentionally ignored + // by the component in this state, so this proves finalization itself + // reconciles the buffered tail. A later End only exposes already-finalized + // content; it must not be the operation that makes it exact. + program.Send(agentruntime.StreamStopped("profile", "root", "normal")) + ack = make(chan struct{}) + program.Send(streamingMotionAck{done: ack}) + <-ack + waitForProgramQuiescence(t, model, writer) + content = make(chan string) + program.Send(streamingMotionRead{content: content}) + require.NotContains(t, <-content, "offscreen", "stream stop must not jump the scrolled-up viewport to the tail") + + program.Send(messages.WheelCoalescedMsg{Delta: 1_000_000, X: 30, Y: 15}) + ack = make(chan struct{}) + program.Send(streamingMotionAck{done: ack}) + <-ack + waitForProgramQuiescence(t, model, writer) + content = make(chan string) + program.Send(streamingMotionRead{content: content}) + require.Contains(t, <-content, "offscreen", "End must reveal exact content finalized at stream stop") + root.ar.Stop() + program.Quit() + require.NoError(t, <-done) +} diff --git a/pkg/tui/tui_transition_root_test.go b/pkg/tui/tui_transition_root_test.go index ba25151a5d..eeffc5203d 100644 --- a/pkg/tui/tui_transition_root_test.go +++ b/pkg/tui/tui_transition_root_test.go @@ -63,7 +63,7 @@ func (m *transitionOwner) View() tea.View { } func TestActualRootTransitionLifecycle(t *testing.T) { - root := wallClockRoot(t, 120, 40) + root, _, _ := wallClockRoot(t, 120, 40) root.ar = animation.NewRuntimeWithScheduler(&rootImmediateScheduler{now: time.Unix(1, 0)}) owner := newTransitionOwner(root) cmd := owner.Init()