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"))) // The ?1049h/l toggles themselves must not reach the host (patterm // owns its own alt screen). On the transition we re-sync host mouse // reporting so codex (which doesn't request mouse) lets the user // drag-select; leaving alt re-arms it for primary-screen wheel // scrollback. want := "a\x1b[?1000l\x1b[?1006lb\x1b[?1000h\x1b[?1006hc" if got != want { t.Fatalf("alt-screen toggles: got %q want %q", got, want) } } func TestViewportRendererMouseTrackingFilteredOnPrimary(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("a\x1b[?1000lb\x1b[?1000hc"))) if got != "abc" { t.Fatalf("mouse mode on primary should be filtered: got %q", got) } } func TestViewportRendererMouseTrackingForwardedOnAlt(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) // Enter alt; subsequent mouse-mode toggles should reach the host so // alt-screen TUIs (vim, less) can run with mouse on, and selection- // using ones (codex) stay with mouse off. got := string(vr.Render([]byte("\x1b[?1049h\x1b[?1000lx\x1b[?1000hy"))) if !strings.Contains(got, "\x1b[?1000l") { t.Fatalf("alt-screen mouse disable should reach host: %q", got) } if !strings.Contains(got, "\x1b[?1000h") { t.Fatalf("alt-screen mouse enable should reach host: %q", got) } } func TestViewportRendererSwallowsOriginModeToggles(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("a\x1b[?6hb\x1b[?6lc"))) if strings.Contains(got, "\x1b[?6h") || strings.Contains(got, "\x1b[?6l") { t.Fatalf("origin-mode toggles leaked to host: %q", got) } if !strings.Contains(got, "a") || !strings.Contains(got, "b") || !strings.Contains(got, "c") { t.Fatalf("origin-mode toggles should not drop surrounding text: got %q", got) } if strings.Count(got, "\x1b[3;1H") != 2 { t.Fatalf("origin-mode set/reset should home inside the viewport twice: got %q", got) } } func TestViewportRendererSwallowsLeftRightMarginMode(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("a\x1b[?69h\x1b[10;80sb\x1b[?69lc"))) if strings.Contains(got, "\x1b[?69h") || strings.Contains(got, "\x1b[10;80s") || strings.Contains(got, "\x1b[?69l") { t.Fatalf("left/right margin controls leaked to host: %q", got) } if got != "abc" { t.Fatalf("left/right margin controls should be swallowed without dropping text: got %q", got) } } func TestViewportRendererOriginModeCUPUsesScrollTop(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("\x1b[5;10r\x1b[?6h\x1b[1;1H"))) if strings.Contains(got, "\x1b[?6h") { t.Fatalf("origin-mode set leaked to host: %q", got) } if !strings.Contains(got, "\x1b[7;1H") { t.Fatalf("CUP row 1 in origin mode should land at scrollTop row 5 shifted to host row 7: 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 TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) _ = vr.Render([]byte("\x1b[37;1H\n")) if !vr.TookScrollAction() { t.Fatalf("LF at viewport bottom should flag scroll") } } func TestViewportRendererDoesNotFlagLineFeedBeforeViewportBottom(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) _ = vr.Render([]byte("\x1b[36;1H\n")) if vr.TookScrollAction() { t.Fatalf("LF before viewport bottom should not flag scroll") } } func TestViewportRendererFlagsLineFeedAtCustomScrollBottom(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) _ = vr.Render([]byte("\x1b[5;10r\x1b[9;1H\n")) if vr.TookScrollAction() { t.Fatalf("LF before custom scroll bottom should not flag scroll") } _ = vr.Render([]byte("\n")) if !vr.TookScrollAction() { t.Fatalf("LF at custom scroll bottom should flag scroll") } } // Long claude sessions can leave the child cursor at viewport row 1 and // then emit CSI A (cursor up) with a large step before redrawing. The // raw CSI A would walk the host cursor into the tab bar; the next // printable would then write into row 1 / row 2. Clamp the step at the // viewport top so the host cursor stays inside the viewport. func TestViewportRendererClampsCUUAtViewportTop(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) // CUP to viewport row 1 then CUU by 50. got := string(vr.Render([]byte("\x1b[1;1H\x1b[50ACLOBBER"))) if !strings.Contains(got, "\x1b[3;1H") { t.Fatalf("expected CUP shifted to mainTop: got %q", got) } // The CUU should have been swallowed (n clamped to 0 from row 1). if strings.Contains(got, "\x1b[50A") { t.Fatalf("CUU 50 from viewport row 1 leaked: got %q", got) } // And the subsequent printables should land inside the viewport, // not above it. if !strings.Contains(got, "CLOBBER") { t.Fatalf("printables should still be emitted after clamped CUU: got %q", got) } } func TestViewportRendererClampsCUUPartial(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) // CUP to viewport row 5, then CUU by 50 → safe step is 4. got := string(vr.Render([]byte("\x1b[5;1H\x1b[50A"))) if !strings.Contains(got, "\x1b[4A") { t.Fatalf("CUU 50 from row 5 should clamp to 4: got %q", got) } if strings.Contains(got, "\x1b[50A") { t.Fatalf("unclamped CUU leaked: got %q", got) } } func TestViewportRendererClampsCUDAtViewportBottom(t *testing.T) { // childRows=37 for layout(120, 40). Park cursor at row 37, ask for // 10 down → safe step is 0. vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("\x1b[37;1H\x1b[10B"))) if strings.Contains(got, "\x1b[10B") { t.Fatalf("CUD past viewport bottom should be dropped: got %q", got) } } func TestViewportRendererClampsCPLAndHomesColumn(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) // CUP to row 1 col 50 then CPL by 5 → step clamped to 0, but col // must still reset to 1 (CR emitted). got := string(vr.Render([]byte("\x1b[1;50H\x1b[5F"))) if strings.Contains(got, "\x1b[5F") { t.Fatalf("CPL 5 from row 1 should not leak: got %q", got) } if !strings.Contains(got, "\r") { t.Fatalf("CPL should home column to 1 with CR: got %q", got) } } func TestViewportRendererClampsCNL(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) // CUP to row 35 then CNL by 50 → safe step is 2 (childRows-35). got := string(vr.Render([]byte("\x1b[35;10H\x1b[50E"))) if !strings.Contains(got, "\x1b[2E") { t.Fatalf("CNL 50 from row 35 should clamp to 2: got %q", got) } } 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) } }