Files
patterm/internal/app/viewport_renderer_test.go
Harry Bayliss 58dbb56937 Repaint sidebar after child scrolls the host scroll region
Codex (Ratatui) emits an 8x RI burst on startup right after setting
DECSTBM. RI at the top of the scroll region scrolls the region down,
and DECSTBM only constrains rows -- so the scroll spans every column
and drags the right-rail session-tree entries down with the main pane.
The chrome cache then hid the clobber because the computed sidebar
frame was unchanged.

The viewport renderer now flags any chunk containing RI / IND / NEL /
SU / SD / IL / DL and OnPTYOut drops the sidebar cache when the flag
is set, so the next drawSidebar repaints over the drift.

Adds unit tests for the new flag and a harness regression scenario
(sidebar_survives_ri_scroll) that fails without the fix.
2026-05-14 20:01:14 +01:00

157 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"strings"
"testing"
)
func TestViewportRendererShiftsCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H")))
if got != "\x1b[3;1H" {
t.Fatalf("CUP home: got %q", got)
}
}
func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("a\x1b[?1049hb\x1b[?1049lc")))
if got != "abc" {
t.Fatalf("alt-screen toggles: got %q", got)
}
}
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
// hostRows=7 leaves four viewport rows after the 2-row tab bar and
// 1-row status reservation.
vr := newViewportRenderer(newTerminalLayout(20, 7))
got := string(vr.Render([]byte("\x1b[2J")))
if strings.Contains(got, "\x1b[2J") {
t.Fatalf("host clear-screen leaked through: %q", got)
}
if strings.Count(got, "\x1b[20X") != 4 {
t.Fatalf("clear rows: got %q", got)
}
if !strings.Contains(got, "\x1b[3;1H") || !strings.Contains(got, "\x1b[6;1H") {
t.Fatalf("clear did not target viewport rows: %q", got)
}
}
func TestViewportRendererClearLineUsesEraseChars(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("\x1b[K")))
if strings.Contains(got, "\x1b[K") {
t.Fatalf("host clear-line leaked through: %q", got)
}
if got != "\x1b[20X" {
t.Fatalf("clear-line: got %q want ECH", got)
}
}
func TestViewportRendererClearLineStopsAtViewportRight(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("\x1b[10G\x1b[K")))
if !strings.HasSuffix(got, "\x1b[11X") {
t.Fatalf("clear-line from col 10 should erase 11 cells: %q", got)
}
}
func TestViewportRendererClearToEndIsViewportOnly(t *testing.T) {
// Reproduces the sidebar-wipe bug: claude's Ctrl+O expansion emits
// `CSI 0 J` (clear from cursor to end of screen). Forwarded verbatim,
// it would erase every host column to the right of the cursor —
// including the sidebar — because the cursor is at host coordinates
// but the J sequence isn't constrained to the viewport.
vr := newViewportRenderer(newTerminalLayout(40, 7))
got := string(vr.Render([]byte("\x1b[H\x1b[0J")))
if strings.Contains(got, "\x1b[0J") || strings.Contains(got, "\x1b[J") {
t.Fatalf("host clear-to-end leaked through: %q", got)
}
// childCols == 19 (40 cols - 28 sidebar - 1 gap - 0-index fudge).
// Each of the 4 viewport rows should get a 19-cell erase.
// childCols == 11 with hostCols=40 (28 sidebar + 1 gap reserved).
// 4 viewport rows, but the cursor row uses ECH at cursor (col 1),
// so we expect 4 erases of 11 cells each.
count := strings.Count(got, "\x1b[11X")
if count != 4 {
t.Fatalf("expected 4 ECH-11 sequences, got %d in %q", count, got)
}
}
func TestViewportRendererClearToStartIsViewportOnly(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(40, 7))
// Park the cursor mid-viewport, then issue `CSI 1 J`.
got := string(vr.Render([]byte("\x1b[3;5H\x1b[1J")))
if strings.Contains(got, "\x1b[1J") {
t.Fatalf("host clear-to-start leaked through: %q", got)
}
// Two full rows above (childCols-wide erase, 11 cells each) plus a
// 5-cell erase on the cursor row.
if !strings.Contains(got, "\x1b[11X") {
t.Fatalf("expected viewport-wide ECH for rows above cursor: %q", got)
}
if !strings.Contains(got, "\x1b[5X") {
t.Fatalf("expected 5-cell ECH on cursor row: %q", got)
}
}
func TestViewportRendererTracksPrintableCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("hello\x1b[K")))
if !strings.HasSuffix(got, "\x1b[15X") {
t.Fatalf("clear-line after five chars should erase 15 cells: %q", got)
}
}
func TestViewportRendererFlagsRIAsScrolling(t *testing.T) {
// Reproduces the sidebar-gap bug: codex emits `\x1b[1;1H` followed
// by 8× `\x1bM` (RI) on startup. RI at the top of the host scroll
// region scrolls the region down — across all columns — pushing
// sidebar content out of place. The renderer must flag the chunk
// so the sidebar cache gets invalidated and repainted afterwards.
vr := newViewportRenderer(newTerminalLayout(120, 40))
if vr.TookScrollAction() {
t.Fatalf("scroll flag set before any input")
}
_ = vr.Render([]byte("\x1b[1;1H"))
if vr.TookScrollAction() {
t.Fatalf("plain CUP should not flag scroll")
}
_ = vr.Render([]byte("\x1bM"))
if !vr.TookScrollAction() {
t.Fatalf("RI (ESC M) should flag scroll")
}
if vr.TookScrollAction() {
t.Fatalf("flag should reset after read")
}
}
func TestViewportRendererFlagsScrollVerbs(t *testing.T) {
cases := map[string][]byte{
"IND": []byte("\x1bD"),
"NEL": []byte("\x1bE"),
"SU": []byte("\x1b[3S"),
"SD": []byte("\x1b[2T"),
}
for name, in := range cases {
t.Run(name, func(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(in)
if !vr.TookScrollAction() {
t.Fatalf("%s should flag scroll", name)
}
})
}
}
func TestViewportRendererForwardsRIVerbatim(t *testing.T) {
// We rely on the host terminal performing the scroll inside the
// DECSTBM region; the renderer must not eat or transform RI. If a
// future change ever rewrites RI, this test catches the regression.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1bM")))
if got != "\x1bM" {
t.Fatalf("RI should pass through unchanged: got %q", got)
}
}