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) } }