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.
157 lines
5.3 KiB
Go
157 lines
5.3 KiB
Go
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)
|
||
}
|
||
}
|