package app import ( "strings" "testing" ) func bytesRepeat(b byte, n int) []byte { out := make([]byte, n) for i := range out { out[i] = b } return out } 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 TestViewportRendererClampsCUPColumn(t *testing.T) { // Layout: hostCols=120, sidebar present → childCols=91. A child that // thinks its viewport is the full host width could emit a CUP to col // 95 (inside the sidebar). The renderer must clamp the emitted CUP // column so the host cursor never lands in the sidebar. vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("\x1b[5;95H"))) if !strings.Contains(got, "\x1b[7;91H") { t.Fatalf("CUP col 95 should clamp to 91 (childCols): got %q", got) } } func TestViewportRendererClampsCHAColumn(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("\x1b[110G"))) if !strings.Contains(got, "\x1b[91G") { t.Fatalf("CHA col 110 should clamp to 91 (childCols): got %q", got) } } func TestViewportRendererDropsPrintablesPastViewport(t *testing.T) { // A child whose internal column state drifted past the viewport // (childCols=91 here) might CUP to col 95 and stream text. The CUP // column is clamped to the viewport edge, but tracking still // considers the cursor "past" — so subsequent printables must drop // rather than walk into the sidebar columns. vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("\x1b[5;95HCLOBBER"))) if strings.Contains(got, "CLOBBER") || strings.Contains(got, "LOBBER") { t.Fatalf("printables past childCols should be dropped: got %q", got) } } func TestViewportRendererKeepsPrintablesUpToViewportEdge(t *testing.T) { // Writing exactly childCols glyphs from col 1 must reach the right // edge unchanged — the drop kicks in only after the cursor passes // the last viewport column. vr := newViewportRenderer(newTerminalLayout(120, 40)) in := append([]byte("\x1b[5;1H"), bytesRepeat('x', 91)...) got := string(vr.Render(in)) if strings.Count(got, "x") != 91 { t.Fatalf("91 'x' glyphs from col 1 should all be emitted: got %q", got) } } func TestViewportRendererDropsUTF8GlyphPastViewport(t *testing.T) { // A 3-byte UTF-8 glyph (U+2500 BOX DRAWINGS LIGHT HORIZONTAL) starting // past the viewport must be dropped as a unit — leaking even one // continuation byte would feed a malformed sequence to the host. vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("\x1b[5;95H─x"))) if strings.Contains(got, "─") { t.Fatalf("UTF-8 glyph past viewport should be dropped: got %q", got) } if strings.Contains(got, "x") { t.Fatalf("trailing ASCII past viewport should also be dropped: got %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) } }