382 lines
14 KiB
Go
382 lines
14 KiB
Go
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[4;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[4;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[8;1H") {
|
||
t.Fatalf("CUP row 1 in origin mode should land at scrollTop row 5 shifted to host row 8: got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
|
||
// hostRows=7 leaves three viewport rows after the 3-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") != 3 {
|
||
t.Fatalf("clear rows: got %q", got)
|
||
}
|
||
if !strings.Contains(got, "\x1b[4;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).
|
||
// childCols == 11 with hostCols=40 (28 sidebar + 1 gap reserved).
|
||
// 3 viewport rows, but the cursor row uses ECH at cursor (col 1),
|
||
// so we expect 3 erases of 11 cells each.
|
||
count := strings.Count(got, "\x1b[11X")
|
||
if count != 3 {
|
||
t.Fatalf("expected 3 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[8;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[36;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[35;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[4;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=36 for layout(120, 40). Park cursor at row 36, ask for
|
||
// 10 down → safe step is 0.
|
||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||
got := string(vr.Render([]byte("\x1b[36;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 34 then CNL by 50 → safe step is 2 (childRows-34).
|
||
got := string(vr.Render([]byte("\x1b[34;10H\x1b[50E")))
|
||
if !strings.Contains(got, "\x1b[2E") {
|
||
t.Fatalf("CNL 50 from row 34 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)
|
||
}
|
||
}
|