diff --git a/internal/vterm/alt_screen_capture.go b/internal/vterm/alt_screen_capture.go new file mode 100644 index 00000000..8206bfcb --- /dev/null +++ b/internal/vterm/alt_screen_capture.go @@ -0,0 +1,196 @@ +package vterm + +// captureScreenToScrollback copies the visible alt-screen frame into the +// scrollback buffer. It trims leading/trailing blank rows and clips each row +// to the current terminal width so only what was actually visible is stored. +// This is called before erase-display in alt-screen mode so that TUI content +// (e.g. Claude Code plan mode) is preserved for amux scroll-back. A dedup +// check avoids storing identical consecutive frames. +func (v *VTerm) captureScreenToScrollback() { + lines := v.visibleCaptureFrame() + if len(lines) == 0 { + return + } + + oldViewOffset := v.ViewOffset + if v.matchesTrackedAltScreenCapture(lines) { + return + } + removed, dropped := v.dropTrackedAltScreenCapture() + + deductOffset := func() { + if oldViewOffset <= 0 { + return + } + v.ViewOffset = oldViewOffset - removed + if v.ViewOffset < 0 { + v.ViewOffset = 0 + } + if v.ViewOffset > len(v.Scrollback) { + v.ViewOffset = len(v.Scrollback) + } + } + + // Dedup: skip if these lines match the tail of scrollback + if matchesScrollbackTail(v.Scrollback, lines) { + v.altScreenCaptureLen = len(lines) + v.altScreenCaptureTracked = len(dropped) > 0 && captureRowsMatch(lines, dropped, v.Width) + deductOffset() + return + } + + added := 0 + for _, line := range lines { + v.Scrollback = append(v.Scrollback, CopyLine(line)) + added++ + } + v.altScreenCaptureLen = added + v.altScreenCaptureTracked = true + if oldViewOffset > 0 { + v.ViewOffset = oldViewOffset - removed + added + if v.ViewOffset < 0 { + v.ViewOffset = 0 + } + if v.ViewOffset > len(v.Scrollback) { + v.ViewOffset = len(v.Scrollback) + } + } + v.trimScrollback() +} + +func (v *VTerm) visibleCaptureFrame() [][]Cell { + visible := make([][]Cell, len(v.Screen)) + firstNonBlank := -1 + lastNonBlank := -1 + + for y, line := range v.Screen { + visible[y] = copyVisibleLine(line, v.Width) + if !isVisiblyBlankLine(visible[y]) { + if firstNonBlank < 0 { + firstNonBlank = y + } + lastNonBlank = y + } + } + + if firstNonBlank < 0 { + return nil + } + return visible[firstNonBlank : lastNonBlank+1] +} + +func copyVisibleLine(line []Cell, width int) []Cell { + if width < 0 { + width = 0 + } + visible := MakeBlankLine(width) + if width == 0 || len(line) == 0 { + return visible + } + n := width + if n > len(line) { + n = len(line) + } + copy(visible, line[:n]) + normalizeLine(visible) + return visible +} + +func isVisiblyBlankLine(line []Cell) bool { + var defaultStyle Style + for _, c := range line { + if c.Rune != ' ' && c.Rune != 0 { + return false + } + if c.Style != defaultStyle { + return false + } + } + return true +} + +func (v *VTerm) matchesTrackedAltScreenCapture(lines [][]Cell) bool { + if v.altScreenCaptureLen <= 0 || !v.altScreenCaptureTracked || v.altScreenCaptureLen != len(lines) { + return false + } + sb := len(v.Scrollback) + if sb < v.altScreenCaptureLen { + v.altScreenCaptureLen = 0 + v.altScreenCaptureTracked = false + return false + } + return matchesScrollbackTail(v.Scrollback, lines) +} + +func (v *VTerm) dropTrackedAltScreenCapture() (int, [][]Cell) { + if v.altScreenCaptureLen <= 0 || !v.altScreenCaptureTracked { + if v.altScreenCaptureLen <= 0 { + return 0, nil + } + v.altScreenCaptureLen = 0 + return 0, nil + } + if len(v.Scrollback) < v.altScreenCaptureLen { + v.altScreenCaptureLen = 0 + v.altScreenCaptureTracked = false + return 0, nil + } + start := len(v.Scrollback) - v.altScreenCaptureLen + // 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:] + removedRows := make([][]Cell, len(src)) + copy(removedRows, src) + removed := v.altScreenCaptureLen + v.Scrollback = v.Scrollback[:len(v.Scrollback)-removed] + v.altScreenCaptureLen = 0 + v.altScreenCaptureTracked = false + return removed, removedRows +} + +func (v *VTerm) invalidateAltScreenCapture() { + v.altScreenCaptureLen = 0 + v.altScreenCaptureTracked = false +} + +// captureRowsMatch compares lines with captured rows using the current terminal width. +func captureRowsMatch(current, captured [][]Cell, width int) bool { + if len(current) != len(captured) { + return false + } + for i := range current { + if !linesEqual(current[i], copyVisibleLine(captured[i], width)) { + return false + } + } + return true +} + +// matchesScrollbackTail returns true if the last len(lines) entries in +// scrollback are cell-identical to lines. +func matchesScrollbackTail(scrollback, lines [][]Cell) bool { + n := len(lines) + sb := len(scrollback) + if sb < n || n == 0 { + return false + } + for i := 0; i < n; i++ { + if !linesEqual(scrollback[sb-n+i], lines[i]) { + return false + } + } + return true +} + +// linesEqual returns true if two cell slices have identical runes and styles. +func linesEqual(a, b []Cell) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Rune != b[i].Rune || a[i].Style != b[i].Style { + return false + } + } + return true +} diff --git a/internal/vterm/alt_screen_erase_capture_test.go b/internal/vterm/alt_screen_erase_capture_test.go new file mode 100644 index 00000000..13b58837 --- /dev/null +++ b/internal/vterm/alt_screen_erase_capture_test.go @@ -0,0 +1,312 @@ +package vterm + +import "testing" + +func TestAltScreenEraseCapturesScrollback(t *testing.T) { + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) // enter alt screen + + // Write content then erase display + vt.Write([]byte("hello\r\nworld")) + vt.Write([]byte("\x1b[2J")) // erase display + + if len(vt.Scrollback) < 2 { + t.Fatalf("expected at least 2 scrollback lines, got %d", len(vt.Scrollback)) + } + + // First captured line should contain "hello" + if vt.Scrollback[0][0].Rune != 'h' { + t.Errorf("expected 'h', got %c", vt.Scrollback[0][0].Rune) + } + // Second captured line should contain "world" + if vt.Scrollback[1][0].Rune != 'w' { + t.Errorf("expected 'w', got %c", vt.Scrollback[1][0].Rune) + } +} + +func TestAltScreenEraseDedup(t *testing.T) { + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // Write content, erase, rewrite identical content, erase again + vt.Write([]byte("hello\r\nworld")) + vt.Write([]byte("\x1b[2J")) + before := len(vt.Scrollback) + + // Redraw identical content + vt.Write([]byte("\x1b[Hhello\r\nworld")) + vt.Write([]byte("\x1b[2J")) + after := len(vt.Scrollback) + + if after != before { + t.Errorf("expected dedup to prevent duplicate capture: before=%d, after=%d", before, after) + } +} + +func TestAltScreenEraseDedupedHistoryIsPreserved(t *testing.T) { + vt := New(5, 2) + vt.AllowAltScreenScrollback = true + line := MakeBlankLine(5) + copy(line, []Cell{ + {Rune: 'f', Width: 1}, + {Rune: 'r', Width: 1}, + {Rune: 'a', Width: 1}, + {Rune: 'm', Width: 1}, + {Rune: 'e', Width: 1}, + }) + vt.Scrollback = append(vt.Scrollback, line) + + vt.Write([]byte("\x1b[?1049h")) + vt.Write([]byte("frame")) + vt.Write([]byte("\x1b[2J")) + + if vt.Scrollback[0][0].Rune != 'f' { + t.Fatalf("expected existing history to remain, got %q", vt.Scrollback[0][0].Rune) + } + if vt.altScreenCaptureLen != 1 { + t.Fatalf("expected deduped frame reserved for resize, got %d", vt.altScreenCaptureLen) + } + if vt.altScreenCaptureTracked { + t.Fatalf("expected deduped frame not tracked as removable capture") + } + + vt.Write([]byte("other")) + vt.Write([]byte("\x1b[2J")) + + if len(vt.Scrollback) != 2 { + t.Fatalf("expected existing history + new capture, got %d rows", len(vt.Scrollback)) + } + if got := vt.Scrollback[0][0].Rune; got != 'f' { + t.Fatalf("expected preexisting row to remain intact, got %q", got) + } +} + +func TestAltScreenEraseBlankNotCaptured(t *testing.T) { + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // Erase a blank screen — nothing should be captured + vt.Write([]byte("\x1b[2J")) + if len(vt.Scrollback) != 0 { + t.Errorf("expected no scrollback for blank screen, got %d", len(vt.Scrollback)) + } +} + +func TestAltScreenEraseNoCaptureWithoutFlag(t *testing.T) { + vt := New(10, 3) + // AllowAltScreenScrollback is false (default) + vt.Write([]byte("\x1b[?1049h")) + + vt.Write([]byte("hello\r\nworld")) + vt.Write([]byte("\x1b[2J")) + + if len(vt.Scrollback) != 0 { + t.Errorf("expected no scrollback without AllowAltScreenScrollback, got %d", len(vt.Scrollback)) + } +} + +func TestNormalScreenEraseNoCapture(t *testing.T) { + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + // NOT in alt screen + + vt.Write([]byte("hello\r\nworld")) + vt.Write([]byte("\x1b[2J")) + + if len(vt.Scrollback) != 0 { + t.Errorf("expected no scrollback capture on normal screen erase, got %d", len(vt.Scrollback)) + } +} + +func TestAltScreenEraseRepaintReplacesPriorCapture(t *testing.T) { + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + // Frame 1 + vt.Write([]byte("frame1")) + vt.Write([]byte("\x1b[2J")) + after1 := len(vt.Scrollback) + + // Frame 2 (different content) + vt.Write([]byte("\x1b[Hframe2")) + vt.Write([]byte("\x1b[2J")) + after2 := len(vt.Scrollback) + + if after1 != 1 { + t.Fatalf("expected first capture to add one row, got %d", after1) + } + if after2 != after1 { + t.Fatalf("expected repaint capture to replace prior frame: after1=%d, after2=%d", after1, after2) + } + if got := vt.Scrollback[len(vt.Scrollback)-1][5].Rune; got != '2' { + t.Fatalf("expected latest captured frame to be retained, got %q", got) + } +} + +func TestAltScreenEraseAnchorsViewOffset(t *testing.T) { + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + for i := 0; i < 2; i++ { + line := MakeBlankLine(10) + line[0] = Cell{Rune: rune('0' + i), Width: 1} + vt.Scrollback = append(vt.Scrollback, line) + } + vt.ViewOffset = 1 + + vt.Write([]byte("\x1b[?1049h")) + vt.Write([]byte("hello\r\nworld")) + vt.Write([]byte("\x1b[2J")) + + if vt.ViewOffset != 3 { + t.Fatalf("expected ViewOffset to advance to 3 after capture, got %d", vt.ViewOffset) + } +} + +func TestAltScreenEraseReplacementPreservesViewOffset(t *testing.T) { + vt := New(10, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + vt.Write([]byte("frame1")) + vt.Write([]byte("\x1b[2J")) + vt.ViewOffset = 1 + + vt.Write([]byte("\x1b[Hframe2")) + vt.Write([]byte("\x1b[2J")) + + if vt.ViewOffset != 1 { + t.Fatalf("expected ViewOffset to remain anchored at 1 during replacement, got %d", vt.ViewOffset) + } +} + +func TestAltScreenEraseTrimsLeadingBlankRows(t *testing.T) { + vt := New(10, 6) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + vt.Write([]byte("\x1b[4;1Hcenter")) + vt.Write([]byte("\x1b[2J")) + + if len(vt.Scrollback) != 1 { + t.Fatalf("expected only the visible non-blank frame rows, got %d lines", len(vt.Scrollback)) + } + if got := vt.Scrollback[0][0].Rune; got != 'c' { + t.Fatalf("expected captured content row to start with 'c', got %q", got) + } +} + +func TestAltScreenEraseClipsCapturedRowsToVisibleWidth(t *testing.T) { + vt := New(8, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + vt.Write([]byte("abcdefgh")) + vt.Resize(4, 3) + + vt.Write([]byte("\x1b[2J")) + + if len(vt.Scrollback) != 1 { + t.Fatalf("expected one captured row, got %d", len(vt.Scrollback)) + } + if got := len(vt.Scrollback[0]); got != 4 { + t.Fatalf("expected captured row width 4, got %d", got) + } + if got := string([]rune{ + vt.Scrollback[0][0].Rune, + vt.Scrollback[0][1].Rune, + vt.Scrollback[0][2].Rune, + vt.Scrollback[0][3].Rune, + }); got != "abcd" { + t.Fatalf("expected visible content \"abcd\", got %q", got) + } +} + +func TestAltScreenEraseDedupedFrameRemainsReservedOnResizeGrow(t *testing.T) { + vt := New(5, 2) + vt.AllowAltScreenScrollback = true + line := MakeBlankLine(5) + copy(line, []Cell{ + {Rune: 'f', Width: 1}, + {Rune: 'r', Width: 1}, + {Rune: 'a', Width: 1}, + {Rune: 'm', Width: 1}, + {Rune: 'e', Width: 1}, + }) + vt.Scrollback = append(vt.Scrollback, line) + + vt.Write([]byte("\x1b[?1049h")) + vt.Write([]byte("frame")) + vt.Write([]byte("\x1b[2J")) + + if vt.altScreenCaptureLen != 1 { + t.Fatalf("expected deduped frame to stay reserved, got %d", vt.altScreenCaptureLen) + } + + vt.Resize(5, 3) + + if got := vt.Screen[0][0].Rune; got != ' ' { + t.Fatalf("expected resized alt screen to stay blank after deduped capture, got %q", got) + } +} + +func TestAltScreenEraseCapturedFrameNotRestoredOnResizeGrow(t *testing.T) { + vt := New(5, 2) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + vt.Write([]byte("frame")) + vt.Write([]byte("\x1b[2J")) + + vt.Resize(5, 3) + + if got := vt.Screen[0][0].Rune; got != ' ' { + t.Fatalf("expected resized alt screen to stay blank, got %q", got) + } + if len(vt.Scrollback) != 1 { + t.Fatalf("expected captured frame to remain in scrollback, got %d rows", len(vt.Scrollback)) + } +} + +func TestAltScreenEraseDropsWideRuneClippedAtEdge(t *testing.T) { + vt := New(4, 2) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + vt.Write([]byte("ab你")) + vt.Resize(3, 2) + + vt.Write([]byte("\x1b[2J")) + + if len(vt.Scrollback) != 1 { + t.Fatalf("expected one captured row, got %d", len(vt.Scrollback)) + } + if got := vt.Scrollback[0][2]; got.Rune != ' ' || got.Width != 1 { + t.Fatalf("expected clipped wide rune to be dropped, got rune=%q width=%d", got.Rune, got.Width) + } +} + +func TestAltScreenErasePreservesStyledSpaceRows(t *testing.T) { + vt := New(4, 3) + vt.AllowAltScreenScrollback = true + vt.Write([]byte("\x1b[?1049h")) + + styled := MakeBlankLine(4) + for i := range styled { + styled[i] = Cell{ + Rune: ' ', + Width: 1, + Style: Style{Bg: Color{Type: ColorIndexed, Value: 1}}, + } + } + vt.Screen[1] = styled + + vt.Write([]byte("\x1b[2J")) + + if len(vt.Scrollback) != 1 { + t.Fatalf("expected styled-space row to be captured, got %d rows", len(vt.Scrollback)) + } + if got := vt.Scrollback[0][0].Style.Bg; got.Type != ColorIndexed || got.Value != 1 { + t.Fatalf("expected captured row to preserve styled background, got %+v", got) + } +} diff --git a/internal/vterm/cursor.go b/internal/vterm/cursor.go index 2dbe559e..fd6ebfe8 100644 --- a/internal/vterm/cursor.go +++ b/internal/vterm/cursor.go @@ -87,6 +87,8 @@ func (v *VTerm) enterAltScreen() { return } v.AltScreen = true + v.altScreenCaptureLen = 0 + v.altScreenCaptureTracked = false v.altCursorX = v.CursorX v.altCursorY = v.CursorY v.altScreenBuf = v.Screen @@ -102,6 +104,8 @@ func (v *VTerm) exitAltScreen() { return } v.AltScreen = false + v.altScreenCaptureLen = 0 + v.altScreenCaptureTracked = false v.Screen = v.altScreenBuf v.altScreenBuf = nil v.CursorX = v.altCursorX diff --git a/internal/vterm/ops.go b/internal/vterm/ops.go index 1cec7133..10ac0b7b 100644 --- a/internal/vterm/ops.go +++ b/internal/vterm/ops.go @@ -176,6 +176,12 @@ func (v *VTerm) eraseDisplay(mode int) { } v.markDirtyRange(0, v.CursorY) case 2, 3: // Entire display (3 also clears scrollback) + // Capture non-blank screen lines to scrollback before erasing, + // so TUI content (like Claude Code plan mode) is preserved for + // amux scroll. Only in alt screen with AllowAltScreenScrollback. + if mode == 2 && v.AltScreen && v.AllowAltScreenScrollback { + v.captureScreenToScrollback() + } for y := 0; y < v.Height; y++ { if y < len(v.Screen) { v.Screen[y] = MakeBlankLine(v.Width) @@ -183,6 +189,7 @@ func (v *VTerm) eraseDisplay(mode int) { } if mode == 3 { v.Scrollback = v.Scrollback[:0] + v.invalidateAltScreenCapture() } v.markDirtyRange(0, v.Height-1) } diff --git a/internal/vterm/scroll.go b/internal/vterm/scroll.go index 5b8b7a3d..938fa98e 100644 --- a/internal/vterm/scroll.go +++ b/internal/vterm/scroll.go @@ -28,6 +28,9 @@ func (v *VTerm) scrollUp(n int) { added++ } } + if added > 0 { + v.invalidateAltScreenCapture() + } if added > 0 && v.ViewOffset > 0 { v.ViewOffset += added if v.ViewOffset > len(v.Scrollback) { diff --git a/internal/vterm/vterm.go b/internal/vterm/vterm.go index b849e35a..7dd8b90d 100644 --- a/internal/vterm/vterm.go +++ b/internal/vterm/vterm.go @@ -33,6 +33,8 @@ 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 @@ -153,6 +155,9 @@ func (v *VTerm) Resize(width, height int) { added++ } } + if added > 0 { + v.invalidateAltScreenCapture() + } if added > 0 && v.ViewOffset > 0 { v.ViewOffset += added if v.ViewOffset > len(v.Scrollback) { @@ -168,13 +173,21 @@ func (v *VTerm) Resize(width, height int) { if height > oldHeight && v.scrollbackEnabled() && v.ViewOffset == 0 { added := height - oldHeight restore := added - if restore > len(v.Scrollback) { - restore = len(v.Scrollback) + reserved := v.altScreenCaptureLen + if reserved > len(v.Scrollback) { + reserved = 0 + v.altScreenCaptureLen = 0 + v.altScreenCaptureTracked = false + } + available := len(v.Scrollback) - reserved + if restore > available { + restore = available } if restore > 0 { - start := len(v.Scrollback) - restore - restored := v.Scrollback[start:] - v.Scrollback = v.Scrollback[:start] + start := available - restore + restored := make([][]Cell, restore) + copy(restored, v.Scrollback[start:available]) + v.Scrollback = append(v.Scrollback[:start], v.Scrollback[available:]...) v.Screen = append(restored, v.Screen...) v.CursorY += restore }