diff --git a/internal/vterm/alt_screen_capture.go b/internal/vterm/alt_screen_capture.go index 8206bfcb..0052908d 100644 --- a/internal/vterm/alt_screen_capture.go +++ b/internal/vterm/alt_screen_capture.go @@ -13,7 +13,16 @@ func (v *VTerm) captureScreenToScrollback() { } oldViewOffset := v.ViewOffset - if v.matchesTrackedAltScreenCapture(lines) { + if matched, dedupRemoved := v.matchesTrackedAltScreenCapture(lines); matched { + if oldViewOffset > 0 { + v.ViewOffset = oldViewOffset - dedupRemoved + if v.ViewOffset < 0 { + v.ViewOffset = 0 + } + if v.ViewOffset > len(v.Scrollback) { + v.ViewOffset = len(v.Scrollback) + } + } return } removed, dropped := v.dropTrackedAltScreenCapture() @@ -34,17 +43,25 @@ func (v *VTerm) captureScreenToScrollback() { // Dedup: skip if these lines match the tail of scrollback if matchesScrollbackTail(v.Scrollback, lines) { v.altScreenCaptureLen = len(lines) + v.altScreenCaptureDropLen = 0 v.altScreenCaptureTracked = len(dropped) > 0 && captureRowsMatch(lines, dropped, v.Width) + if v.altScreenCaptureTracked { + v.altScreenCaptureDropLen = len(lines) + } deductOffset() return } + // Partial overlap detection — skip lines already in scrollback from scrollUp + overlap := scrollbackTailOverlap(v.Scrollback, lines) + added := 0 - for _, line := range lines { + for _, line := range lines[overlap:] { v.Scrollback = append(v.Scrollback, CopyLine(line)) added++ } - v.altScreenCaptureLen = added + v.altScreenCaptureLen = len(lines) + v.altScreenCaptureDropLen = added v.altScreenCaptureTracked = true if oldViewOffset > 0 { v.ViewOffset = oldViewOffset - removed + added @@ -109,48 +126,150 @@ func isVisiblyBlankLine(line []Cell) bool { return true } -func (v *VTerm) matchesTrackedAltScreenCapture(lines [][]Cell) bool { - if v.altScreenCaptureLen <= 0 || !v.altScreenCaptureTracked || v.altScreenCaptureLen != len(lines) { - return false +// matchesTrackedAltScreenCapture checks if lines match the reserved +// alt-screen content. Tracked captures may no longer be at the scrollback tail +// if scrollUp has appended lines after them (tracked by +// altScreenCaptureEndOffset); untracked captures must still match the tail. +func (v *VTerm) matchesTrackedAltScreenCapture(lines [][]Cell) (bool, int) { + if v.altScreenCaptureLen <= 0 || v.altScreenCaptureLen != len(lines) { + return false, 0 + } + if !v.altScreenCaptureTracked { + if matchesScrollbackTail(v.Scrollback, lines) { + return true, 0 + } + return false, 0 } + total := v.altScreenCaptureLen + v.altScreenCaptureEndOffset sb := len(v.Scrollback) - if sb < v.altScreenCaptureLen { + if sb < total { v.altScreenCaptureLen = 0 + v.altScreenCaptureDropLen = 0 v.altScreenCaptureTracked = false - return false + v.altScreenCaptureEndOffset = 0 + return false, 0 + } + captureStart := sb - total + for i := 0; i < v.altScreenCaptureLen; i++ { + if !linesEqual(v.Scrollback[captureStart+i], lines[i]) { + return false, 0 + } } - return matchesScrollbackTail(v.Scrollback, lines) + + // Match confirmed — dedup scrollUp trailing lines that duplicate + // content already present in the pre-capture scrollback. + removed := v.dedupScrollUpTrailing(captureStart) + return true, removed } +// dropTrackedAltScreenCapture removes the tracked suffix for the previously +// reserved alt-screen frame from scrollback. With altScreenCaptureEndOffset, +// the tracked suffix may be in the middle of scrollback (not at the tail), so +// we remove from its tracked position and preserve any overlap prefix plus +// trailing scrollUp lines. func (v *VTerm) dropTrackedAltScreenCapture() (int, [][]Cell) { if v.altScreenCaptureLen <= 0 || !v.altScreenCaptureTracked { if v.altScreenCaptureLen <= 0 { + v.altScreenCaptureDropLen = 0 + v.altScreenCaptureEndOffset = 0 return 0, nil } v.altScreenCaptureLen = 0 + v.altScreenCaptureDropLen = 0 + v.altScreenCaptureEndOffset = 0 + return 0, nil + } + if v.altScreenCaptureDropLen <= 0 || v.altScreenCaptureDropLen > v.altScreenCaptureLen { + v.altScreenCaptureLen = 0 + v.altScreenCaptureDropLen = 0 + v.altScreenCaptureTracked = false + v.altScreenCaptureEndOffset = 0 return 0, nil } - if len(v.Scrollback) < v.altScreenCaptureLen { + total := v.altScreenCaptureLen + v.altScreenCaptureEndOffset + if len(v.Scrollback) < total { v.altScreenCaptureLen = 0 + v.altScreenCaptureDropLen = 0 v.altScreenCaptureTracked = false + v.altScreenCaptureEndOffset = 0 return 0, nil } - start := len(v.Scrollback) - v.altScreenCaptureLen + captureStart := len(v.Scrollback) - total + captureEnd := captureStart + v.altScreenCaptureLen + dropStart := captureEnd - v.altScreenCaptureDropLen + // Copy the removed rows so the returned slice doesn't alias the // Scrollback backing array — a subsequent append could overwrite it. - src := v.Scrollback[start:] + src := v.Scrollback[dropStart:captureEnd] removedRows := make([][]Cell, len(src)) copy(removedRows, src) - removed := v.altScreenCaptureLen - v.Scrollback = v.Scrollback[:len(v.Scrollback)-removed] + removed := v.altScreenCaptureDropLen + + // Remove the tracked suffix from the frame while preserving any overlapping + // prefix that was already in scrollback and any trailing scrollUp lines. + v.Scrollback = append(v.Scrollback[:dropStart], v.Scrollback[captureEnd:]...) v.altScreenCaptureLen = 0 + v.altScreenCaptureDropLen = 0 v.altScreenCaptureTracked = false + + // Dedup scrollUp trailing lines against pre-capture scrollback. + // After removal, trailing lines are at [dropStart, dropStart+endOffset). + dedupRemoved := v.dedupScrollUpTrailing(dropStart) + removed += dedupRemoved + v.altScreenCaptureEndOffset = 0 + return removed, removedRows } +// dedupScrollUpTrailing removes scrollUp lines from the scrollback that +// duplicate content already present in the pre-capture scrollback region +// (scrollback[:preCaptureLen]). This prevents duplication when TUI redraws +// cause the same content to scroll off multiple times across erase cycles. +// +// Known limitation: only compares trailing lines against pre-capture content. +// When above-fold content changes across cycles but below-fold stays the same, +// trailing lines accumulate without dedup (e.g. [X,Y] -> [X,Y,X,Y] -> ...). +// This is bounded by MaxScrollback and only affects edge cases where the top +// portion of a TUI changes while the bottom stays identical. Fixing would +// require tracking new-vs-old trailing lines to detect internal repetition. +func (v *VTerm) dedupScrollUpTrailing(preCaptureLen int) int { + trailing := v.altScreenCaptureEndOffset + if trailing <= 0 { + v.altScreenCaptureEndOffset = 0 + return 0 + } + + if preCaptureLen <= 0 || preCaptureLen > len(v.Scrollback) { + return 0 + } + + before := v.Scrollback[:preCaptureLen] + trailingStart := len(v.Scrollback) - trailing + if trailingStart < preCaptureLen { + trailingStart = preCaptureLen + } + if trailingStart >= len(v.Scrollback) { + return 0 + } + + trailingLines := v.Scrollback[trailingStart:] + + overlap := scrollbackTailOverlap(before, trailingLines) + if overlap <= 0 { + return 0 + } + + // Remove the overlapping prefix from the trailing lines + v.Scrollback = append(v.Scrollback[:trailingStart], v.Scrollback[trailingStart+overlap:]...) + v.altScreenCaptureEndOffset = trailing - overlap + return overlap +} + func (v *VTerm) invalidateAltScreenCapture() { v.altScreenCaptureLen = 0 + v.altScreenCaptureDropLen = 0 v.altScreenCaptureTracked = false + v.altScreenCaptureEndOffset = 0 } // captureRowsMatch compares lines with captured rows using the current terminal width. @@ -182,6 +301,29 @@ func matchesScrollbackTail(scrollback, lines [][]Cell) bool { return true } +// scrollbackTailOverlap returns the length of the longest suffix of scrollback +// that matches a prefix of lines. This detects lines already pushed into +// scrollback by scrollUp so captureScreenToScrollback can skip them. +func scrollbackTailOverlap(scrollback, lines [][]Cell) int { + maxK := len(lines) + if len(scrollback) < maxK { + maxK = len(scrollback) + } + for k := maxK; k > 0; k-- { + match := true + for i := 0; i < k; i++ { + if !linesEqual(scrollback[len(scrollback)-k+i], lines[i]) { + match = false + break + } + } + if match { + return k + } + } + return 0 +} + // linesEqual returns true if two cell slices have identical runes and styles. func linesEqual(a, b []Cell) bool { if len(a) != len(b) { diff --git a/internal/vterm/alt_screen_erase_capture_test.go b/internal/vterm/alt_screen_erase_capture_test.go index 13b58837..de3031fe 100644 --- a/internal/vterm/alt_screen_erase_capture_test.go +++ b/internal/vterm/alt_screen_erase_capture_test.go @@ -1,6 +1,8 @@ package vterm -import "testing" +import ( + "testing" +) func TestAltScreenEraseCapturesScrollback(t *testing.T) { vt := New(10, 3) @@ -183,6 +185,24 @@ func TestAltScreenEraseReplacementPreservesViewOffset(t *testing.T) { } } +func TestAltScreenEraseTrackedMatchDedupsViewOffset(t *testing.T) { + vt := New(10, 4) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + vt.Write([]byte("line01\r\nline02\r\nline03\r\nline04\r\nline05\r\nline06")) + vt.Write([]byte("\x1b[2J")) + vt.ViewOffset = 2 + + vt.Write([]byte("\x1b[H")) + vt.Write([]byte("line01\r\nline02\r\nline03\r\nline04\r\nline05\r\nline06")) + vt.Write([]byte("\x1b[2J")) + + if vt.ViewOffset != 2 { + t.Fatalf("expected ViewOffset to remain anchored at 2 after matched redraw dedup, got %d", vt.ViewOffset) + } +} + func TestAltScreenEraseTrimsLeadingBlankRows(t *testing.T) { vt := New(10, 6) vt.AllowAltScreenScrollback = true @@ -310,3 +330,78 @@ func TestAltScreenErasePreservesStyledSpaceRows(t *testing.T) { t.Fatalf("expected captured row to preserve styled background, got %+v", got) } } + +// lineText extracts a string from a []Cell row, trimming trailing spaces. +func lineText(line []Cell) string { + var s []rune + for _, c := range line { + if c.Rune == 0 { + s = append(s, ' ') + } else { + s = append(s, c.Rune) + } + } + for len(s) > 0 && s[len(s)-1] == ' ' { + s = s[:len(s)-1] + } + return string(s) +} + +func dumpScrollback(t *testing.T, vt *VTerm) { + t.Helper() + for i, line := range vt.Scrollback { + t.Logf(" scrollback[%d] = %q", i, lineText(line)) + } +} + +func TestAltScreenEraseDedupsScrollUpOverlap(t *testing.T) { + // Simulate content that overflows a 4-row terminal: + // 6 lines of content → first 2 scroll off via scrollUp, + // last 4 remain on screen. After erase+redraw cycle(s), + // scrollback should not accumulate duplicates. + vt := New(10, 4) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // First draw: 6 lines on a 4-row screen + vt.Write([]byte("line01\r\nline02\r\nline03\r\nline04\r\nline05\r\nline06")) + // scrollUp pushed line01, line02 into scrollback + vt.Write([]byte("\x1b[2J")) // erase captures line03-line06 + + sbAfterFirst := len(vt.Scrollback) + + // Redraw from cursor home (simulating TUI repaint) + vt.Write([]byte("\x1b[H")) + vt.Write([]byte("line01\r\nline02\r\nline03\r\nline04\r\nline05\r\nline06")) + vt.Write([]byte("\x1b[2J")) + + sbAfterSecond := len(vt.Scrollback) + + if sbAfterSecond != sbAfterFirst { + t.Errorf("scrollback should stay stable across redraws: first=%d, second=%d", + sbAfterFirst, sbAfterSecond) + dumpScrollback(t, vt) + } + + // Third cycle + vt.Write([]byte("\x1b[H")) + vt.Write([]byte("line01\r\nline02\r\nline03\r\nline04\r\nline05\r\nline06")) + vt.Write([]byte("\x1b[2J")) + + if len(vt.Scrollback) != sbAfterFirst { + t.Errorf("scrollback should remain stable after 3rd redraw: expected=%d, got=%d", + sbAfterFirst, len(vt.Scrollback)) + dumpScrollback(t, vt) + } + + // Verify no adjacent duplicate lines + for i := 1; i < len(vt.Scrollback); i++ { + prev := lineText(vt.Scrollback[i-1]) + cur := lineText(vt.Scrollback[i]) + if prev == cur && prev != "" { + t.Errorf("duplicate adjacent scrollback at index %d: %q", i, cur) + dumpScrollback(t, vt) + break + } + } +} diff --git a/internal/vterm/alt_screen_erase_overlap_test.go b/internal/vterm/alt_screen_erase_overlap_test.go new file mode 100644 index 00000000..a3af1148 --- /dev/null +++ b/internal/vterm/alt_screen_erase_overlap_test.go @@ -0,0 +1,458 @@ +package vterm + +import ( + "fmt" + "testing" +) + +func TestAltScreenEraseNoOverlapWhenContentDiffers(t *testing.T) { + // Verify that overlap detection does not falsely match when content changes. + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // Frame 1: overflow with AAA, BBB, CCC, DDD (AAA scrolls off) + vt.Write([]byte("AAA\r\nBBB\r\nCCC\r\nDDD")) + vt.Write([]byte("\x1b[2J")) + + // Frame 2: completely different content that overflows + vt.Write([]byte("\x1b[H")) + vt.Write([]byte("XXX\r\nYYY\r\nZZZ\r\nWWW")) + vt.Write([]byte("\x1b[2J")) + + // All new content should be present — no false dedup + found := map[string]bool{} + for _, line := range vt.Scrollback { + text := lineText(line) + if text != "" { + found[text] = true + } + } + + for _, want := range []string{"YYY", "ZZZ", "WWW"} { + if !found[want] { + t.Errorf("expected %q in scrollback, not found", want) + dumpScrollback(t, vt) + break + } + } +} + +func TestAltScreenErasePartialOverlap(t *testing.T) { + // Manually set up scrollback to have specific tail lines, + // then verify partial overlap detection works. + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // makeLine creates a []Cell row with given text. + makeLine := func(text string) []Cell { + line := MakeBlankLine(10) + for i, r := range text { + if i >= 10 { + break + } + line[i] = Cell{Rune: r, Width: 1} + } + return line + } + + // Pre-populate scrollback with lines that partially overlap + // with what the screen capture will produce. + vt.Scrollback = append(vt.Scrollback, makeLine("alpha")) + vt.Scrollback = append(vt.Scrollback, makeLine("beta")) + + // Put "beta" and "gamma" on screen (beta overlaps with scrollback tail) + vt.Screen[0] = makeLine("beta") + vt.Screen[1] = makeLine("gamma") + // row 2 is blank + + vt.Write([]byte("\x1b[2J")) + + // Should have: alpha, beta, gamma (not alpha, beta, beta, gamma) + if len(vt.Scrollback) != 3 { + t.Fatalf("expected 3 scrollback lines, got %d", len(vt.Scrollback)) + } + + expected := []string{"alpha", "beta", "gamma"} + for i, want := range expected { + got := lineText(vt.Scrollback[i]) + if got != want { + t.Errorf("scrollback[%d] = %q, want %q", i, got, want) + } + } +} + +func TestAltScreenEraseOverlapAcrossMultipleRedraws(t *testing.T) { + // Verify duplication doesn't compound over 5 erase cycles + // with content that overflows the terminal. + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // First draw + vt.Write([]byte("line1\r\nline2\r\nline3\r\nline4\r\nline5")) + vt.Write([]byte("\x1b[2J")) + firstLen := len(vt.Scrollback) + + for cycle := 1; cycle < 5; cycle++ { + vt.Write([]byte("\x1b[H")) + vt.Write([]byte("line1\r\nline2\r\nline3\r\nline4\r\nline5")) + vt.Write([]byte("\x1b[2J")) + } + + if len(vt.Scrollback) != firstLen { + t.Errorf("scrollback should stay stable: first=%d, after5=%d", + firstLen, len(vt.Scrollback)) + dumpScrollback(t, vt) + } + + // Verify no adjacent duplicates + for i := 1; i < len(vt.Scrollback); i++ { + prev := lineText(vt.Scrollback[i-1]) + cur := lineText(vt.Scrollback[i]) + if prev == cur && prev != "" { + t.Errorf("adjacent duplicate at index %d: %q", i, cur) + dumpScrollback(t, vt) + break + } + } +} + +func TestAltScreenEraseContentChangeAfterOverflow(t *testing.T) { + // After overflowing content, changing the content should replace + // the old capture properly. + vt := New(10, 4) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // Frame 1: 6 lines overflow 4-row terminal + vt.Write([]byte("aaa\r\nbbb\r\nccc\r\nddd\r\neee\r\nfff")) + vt.Write([]byte("\x1b[2J")) + + // Frame 2: different 6 lines + vt.Write([]byte("\x1b[H")) + vt.Write([]byte("111\r\n222\r\n333\r\n444\r\n555\r\n666")) + vt.Write([]byte("\x1b[2J")) + + // Old capture (ccc-fff) should be replaced, not orphaned + found := map[string]int{} + for _, line := range vt.Scrollback { + text := lineText(line) + if text != "" { + found[text]++ + } + } + + // New content should be present + for _, want := range []string{"111", "222", "333", "444", "555", "666"} { + if found[want] == 0 { + t.Errorf("expected %q in scrollback, not found", want) + dumpScrollback(t, vt) + return + } + } + + // Old scrollUp lines (aaa, bbb) may persist, old capture lines should not + for _, old := range []string{"ccc", "ddd", "eee", "fff"} { + if found[old] > 0 { + t.Errorf("old capture line %q should be removed, found %d times", old, found[old]) + dumpScrollback(t, vt) + return + } + } +} + +func TestAltScreenScrollbackTailOverlap(t *testing.T) { + makeLine := func(text string, width int) []Cell { + line := MakeBlankLine(width) + for i, r := range text { + if i >= width { + break + } + line[i] = Cell{Rune: r, Width: 1} + } + return line + } + + tests := []struct { + name string + sb []string + lines []string + expected int + }{ + {"no overlap", []string{"A", "B"}, []string{"C", "D"}, 0}, + {"full overlap", []string{"A", "B"}, []string{"A", "B"}, 2}, + {"partial overlap 1", []string{"A", "B", "C"}, []string{"C", "D"}, 1}, + {"partial overlap 2", []string{"A", "B", "C"}, []string{"B", "C", "D"}, 2}, + {"empty scrollback", nil, []string{"A"}, 0}, + {"empty lines", []string{"A"}, nil, 0}, + {"single match", []string{"X"}, []string{"X", "Y"}, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sb [][]Cell + for _, s := range tt.sb { + sb = append(sb, makeLine(s, 5)) + } + var lines [][]Cell + for _, s := range tt.lines { + lines = append(lines, makeLine(s, 5)) + } + got := scrollbackTailOverlap(sb, lines) + if got != tt.expected { + t.Errorf("scrollbackTailOverlap() = %d, want %d", got, tt.expected) + } + }) + } +} + +func TestAltScreenEraseManyOverflowCyclesStable(t *testing.T) { + // Stress test: 20 redraw cycles of overflowing content. + // Scrollback should stabilize and never grow unbounded. + vt := New(10, 4) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // Generate 8 lines of content (overflows 4-row terminal) + content := "L01\r\nL02\r\nL03\r\nL04\r\nL05\r\nL06\r\nL07\r\nL08" + + vt.Write([]byte(content)) + vt.Write([]byte("\x1b[2J")) + stableLen := len(vt.Scrollback) + + for i := 0; i < 20; i++ { + vt.Write([]byte("\x1b[H")) + vt.Write([]byte(content)) + vt.Write([]byte("\x1b[2J")) + } + + if len(vt.Scrollback) != stableLen { + t.Errorf("scrollback grew after 20 cycles: expected %d, got %d", + stableLen, len(vt.Scrollback)) + dumpScrollback(t, vt) + } + + // Verify content is correct: L01..L08, each once + for i, line := range vt.Scrollback { + expected := fmt.Sprintf("L%02d", i+1) + got := lineText(line) + if got != expected { + t.Errorf("scrollback[%d] = %q, want %q", i, got, expected) + } + } +} + +func TestAltScreenErasePartialOverlapReservesFullFrameOnResizeGrow(t *testing.T) { + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + makeLine := func(text string) []Cell { + line := MakeBlankLine(10) + for i, r := range text { + if i >= 10 { + break + } + line[i] = Cell{Rune: r, Width: 1} + } + return line + } + + vt.Scrollback = append(vt.Scrollback, makeLine("alpha")) + vt.Scrollback = append(vt.Scrollback, makeLine("beta")) + vt.Screen[0] = makeLine("beta") + vt.Screen[1] = makeLine("gamma") + + vt.Write([]byte("\x1b[2J")) + + if vt.altScreenCaptureLen != 2 { + t.Fatalf("captureLen = %d, want 2", vt.altScreenCaptureLen) + } + if vt.altScreenCaptureDropLen != 1 { + t.Fatalf("dropLen = %d, want 1", vt.altScreenCaptureDropLen) + } + if !vt.altScreenCaptureTracked { + t.Fatal("expected partial-overlap capture to stay tracked") + } + + vt.Resize(10, 4) + + if got := lineText(vt.Screen[0]); got != "alpha" { + t.Fatalf("expected resize grow to restore only pre-frame history, got %q", got) + } + if got := lineText(vt.Screen[1]); got != "" { + t.Fatalf("expected overlapping frame rows to remain reserved, got %q", got) + } +} + +func TestAltScreenEndOffsetPreservedOnPartialDedup(t *testing.T) { + // Regression: dedupScrollUpTrailing must not zero altScreenCaptureEndOffset + // when trailing lines don't overlap with pre-capture content. + // Scenario: cycle 1 captures [C,D,E], cycle 2 adds scrollUp lines [X,Y] + // that don't match pre-capture [A,B]. endOffset must remain 2 so cycle 3 + // computes the correct capture position. + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // Cycle 1: draw A,B,C,D,E on 3-row screen → A,B scroll off, C/D/E on screen + vt.Write([]byte("AAA\r\nBBB\r\nCCC\r\nDDD\r\nEEE")) + vt.Write([]byte("\x1b[2J")) + + // Cycle 2: different above-fold content, same below-fold + vt.Write([]byte("\x1b[H")) + vt.Write([]byte("XXX\r\nYYY\r\nCCC\r\nDDD\r\nEEE")) + vt.Write([]byte("\x1b[2J")) + + // Cycle 3: identical redraw — should dedup cleanly + vt.Write([]byte("\x1b[H")) + vt.Write([]byte("XXX\r\nYYY\r\nCCC\r\nDDD\r\nEEE")) + vt.Write([]byte("\x1b[2J")) + + // Verify no adjacent duplicates + for i := 1; i < len(vt.Scrollback); i++ { + prev := lineText(vt.Scrollback[i-1]) + cur := lineText(vt.Scrollback[i]) + if prev == cur && prev != "" { + t.Errorf("duplicate adjacent scrollback at index %d: %q", i, cur) + dumpScrollback(t, vt) + return + } + } +} + +func TestResizeGrowReservesTrackedCaptureEndOffset(t *testing.T) { + vt := New(5, 3) + vt.AllowAltScreenScrollback = true + vt.AltScreen = true + + makeLine := func(text string) []Cell { + line := MakeBlankLine(5) + for i, r := range text { + if i >= 5 { + break + } + line[i] = Cell{Rune: r, Width: 1} + } + return line + } + + vt.Scrollback = append(vt.Scrollback, + makeLine("hist1"), + makeLine("hist2"), + makeLine("cap1"), + makeLine("cap2"), + makeLine("cap3"), + makeLine("tail"), + ) + vt.altScreenCaptureLen = 3 + vt.altScreenCaptureDropLen = 3 + vt.altScreenCaptureTracked = true + vt.altScreenCaptureEndOffset = 1 + + vt.Resize(5, 5) + + if got := lineText(vt.Screen[0]); got != "hist1" { + t.Fatalf("screen[0] = %q, want hist1", got) + } + if got := lineText(vt.Screen[1]); got != "hist2" { + t.Fatalf("screen[1] = %q, want hist2", got) + } + if got := lineText(vt.Screen[2]); got != "" { + t.Fatalf("expected reserved capture/tail rows to stay off-screen, got %q", got) + } +} + +func TestScrollUpCustomScrollRegionPreservesTrackedAltScreenCaptureReplacement(t *testing.T) { + vt := New(10, 4) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + makeLine := func(text string) []Cell { + line := MakeBlankLine(10) + for i, r := range text { + if i >= 10 { + break + } + line[i] = Cell{Rune: r, Width: 1} + } + return line + } + + vt.Screen[0] = makeLine("one") + vt.Screen[1] = makeLine("two") + vt.captureScreenToScrollback() + + vt.Screen[0] = makeLine("status") + vt.Screen[1] = makeLine("one") + vt.Screen[2] = makeLine("two") + vt.Screen[3] = makeLine("three") + vt.ScrollTop = 1 + vt.ScrollBottom = 4 + vt.scrollUp(1) + vt.Screen[3] = makeLine("four") + + if vt.altScreenCaptureEndOffset != 1 { + t.Fatalf("endOffset = %d, want 1 after region scroll", vt.altScreenCaptureEndOffset) + } + + vt.captureScreenToScrollback() + + want := []string{"one", "status", "two", "three", "four"} + if len(vt.Scrollback) != len(want) { + dumpScrollback(t, vt) + t.Fatalf("scrollback length = %d, want %d", len(vt.Scrollback), len(want)) + } + for i, w := range want { + if got := lineText(vt.Scrollback[i]); got != w { + dumpScrollback(t, vt) + t.Fatalf("scrollback[%d] = %q, want %q", i, got, w) + } + } +} + +func TestAltScreenDropRecaptureResetsEndOffset(t *testing.T) { + // Regression: dropTrackedAltScreenCapture must zero altScreenCaptureEndOffset + // after dedup so the next capture starts with a clean offset. Without the fix, + // stale endOffset from cycle 1 causes captureStart miscalculation in cycle 3. + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // Cycle 1: AAA,BBB scroll off; CCC,DDD,EEE captured + vt.Write([]byte("AAA\r\nBBB\r\nCCC\r\nDDD\r\nEEE")) + vt.Write([]byte("\x1b[2J")) + + if vt.altScreenCaptureLen != 3 { + t.Fatalf("cycle 1: captureLen = %d, want 3", vt.altScreenCaptureLen) + } + + // Cycle 2: completely different content — forces drop+recapture + vt.Write([]byte("\x1b[H")) + vt.Write([]byte("PPP\r\nQQQ\r\nRRR\r\nSSS\r\nTTT")) + vt.Write([]byte("\x1b[2J")) + + if vt.altScreenCaptureEndOffset != 0 { + t.Fatalf("cycle 2: endOffset = %d after drop+recapture, want 0", + vt.altScreenCaptureEndOffset) + } + + // Cycle 3: same as cycle 2 — should match cleanly + vt.Write([]byte("\x1b[H")) + vt.Write([]byte("PPP\r\nQQQ\r\nRRR\r\nSSS\r\nTTT")) + vt.Write([]byte("\x1b[2J")) + + want := []string{"AAA", "BBB", "PPP", "QQQ", "RRR", "SSS", "TTT"} + if len(vt.Scrollback) != len(want) { + dumpScrollback(t, vt) + t.Fatalf("scrollback length = %d, want %d", len(vt.Scrollback), len(want)) + } + for i, w := range want { + got := lineText(vt.Scrollback[i]) + if got != w { + t.Errorf("scrollback[%d] = %q, want %q", i, got, w) + } + } +} diff --git a/internal/vterm/cursor.go b/internal/vterm/cursor.go index fd6ebfe8..f3a9405c 100644 --- a/internal/vterm/cursor.go +++ b/internal/vterm/cursor.go @@ -88,7 +88,9 @@ func (v *VTerm) enterAltScreen() { } v.AltScreen = true v.altScreenCaptureLen = 0 + v.altScreenCaptureDropLen = 0 v.altScreenCaptureTracked = false + v.altScreenCaptureEndOffset = 0 v.altCursorX = v.CursorX v.altCursorY = v.CursorY v.altScreenBuf = v.Screen @@ -105,7 +107,9 @@ func (v *VTerm) exitAltScreen() { } v.AltScreen = false v.altScreenCaptureLen = 0 + v.altScreenCaptureDropLen = 0 v.altScreenCaptureTracked = false + v.altScreenCaptureEndOffset = 0 v.Screen = v.altScreenBuf v.altScreenBuf = nil v.CursorX = v.altCursorX diff --git a/internal/vterm/scroll.go b/internal/vterm/scroll.go index 938fa98e..22d4eefd 100644 --- a/internal/vterm/scroll.go +++ b/internal/vterm/scroll.go @@ -29,7 +29,12 @@ func (v *VTerm) scrollUp(n int) { } } if added > 0 { - v.invalidateAltScreenCapture() + if v.altScreenCaptureTracked && v.altScreenCaptureLen > 0 && + v.altScreenCaptureDropLen > 0 { + v.altScreenCaptureEndOffset += added + } else { + v.invalidateAltScreenCapture() + } } if added > 0 && v.ViewOffset > 0 { v.ViewOffset += added diff --git a/internal/vterm/vterm.go b/internal/vterm/vterm.go index 7dd8b90d..2e01a1c9 100644 --- a/internal/vterm/vterm.go +++ b/internal/vterm/vterm.go @@ -33,11 +33,15 @@ type VTerm struct { // AllowAltScreenScrollback keeps scrollback active even in alt screen. // Useful for tmux-backed sessions where scrollback should remain available. AllowAltScreenScrollback bool - altScreenCaptureLen int - altScreenCaptureTracked bool - altScreenBuf [][]Cell - altCursorX int - altCursorY int + // altScreenCaptureLen tracks the full reserved frame length in scrollback. + altScreenCaptureLen int + // altScreenCaptureDropLen tracks the removable suffix for the reserved frame. + altScreenCaptureDropLen int + altScreenCaptureTracked bool + altScreenCaptureEndOffset int + altScreenBuf [][]Cell + altCursorX int + altCursorY int // Scrolling region (for DECSTBM) ScrollTop int @@ -173,11 +177,12 @@ func (v *VTerm) Resize(width, height int) { if height > oldHeight && v.scrollbackEnabled() && v.ViewOffset == 0 { added := height - oldHeight restore := added - reserved := v.altScreenCaptureLen + reserved := v.altScreenCaptureLen + v.altScreenCaptureEndOffset if reserved > len(v.Scrollback) { reserved = 0 v.altScreenCaptureLen = 0 v.altScreenCaptureTracked = false + v.altScreenCaptureEndOffset = 0 } available := len(v.Scrollback) - reserved if restore > available { diff --git a/internal/vterm/vterm_test.go b/internal/vterm/vterm_test.go index a2ed8595..ca888974 100644 --- a/internal/vterm/vterm_test.go +++ b/internal/vterm/vterm_test.go @@ -312,6 +312,47 @@ func TestResizeRestoresScrollbackOnGrow(t *testing.T) { } } +func TestTrimScrollbackPreservesTrackedAltScreenCapturePosition(t *testing.T) { + vt := New(5, 2) + + makeLine := func(text string) []Cell { + line := MakeBlankLine(5) + for i, r := range text { + if i >= 5 { + break + } + line[i] = Cell{Rune: r, Width: 1} + } + return line + } + + for i := 0; i < MaxScrollback-1; i++ { + vt.Scrollback = append(vt.Scrollback, makeLine("old")) + } + vt.Scrollback = append(vt.Scrollback, + makeLine("cap1"), + makeLine("cap2"), + makeLine("tail"), + ) + vt.altScreenCaptureLen = 2 + vt.altScreenCaptureDropLen = 2 + vt.altScreenCaptureTracked = true + vt.altScreenCaptureEndOffset = 1 + + vt.trimScrollback() + + matched, removed := vt.matchesTrackedAltScreenCapture([][]Cell{ + makeLine("cap1"), + makeLine("cap2"), + }) + if !matched { + t.Fatal("expected tracked capture to remain end-relative after trim") + } + if removed != 0 { + t.Fatalf("expected no trailing dedup removal after trim, got %d", removed) + } +} + func TestIncrementalCursorPositionedWrites(t *testing.T) { // Test cursor positioning + partial writes (common in Ink/React TUIs, progress bars, etc.) vt := New(20, 3)