Files
patterm/internal/app/viewport_renderer_test.go
Harry Bayliss 52e06c914e
Some checks failed
release / build-linux-amd64 (push) Failing after 10m52s
Release v0.0.1
Bundles the in-flight work into the first tagged release. See
CHANGELOG.md `[0.0.1] - 2026-05-14` for the full per-change list.
Highlights:

- Sidebar / chrome stability: clamp absolute cursor positioning and
  printable bytes to the viewport so long-running TUIs (claude, codex)
  can't spray into the right rail; bound tab bar's row clear to the
  viewport width so the rail isn't wiped on every tab redraw; flag
  scroll escapes (RI/IND/NEL/SU/SD/IL/DL) and clamp `CSI 0/1/2 J`/`K`
  to viewport columns.
- Palette: "Spawn process…" form, macros (`sw `, `k `, `sp `), kill
  entries mark the focused tab, dead agents drop out of the switch
  list.
- Sidebar: split into Processes (session-wide) + Agent Tree
  (per-active-agent) sections; relaunch indicator; Ctrl+W/S walks the
  combined list, Ctrl+A/D steps tabs.
- MCP: protocol handshake (`initialize`, `tools/list`, `tools/call`,
  `ping`), `mcp_injection.kind = cli_override / config_env` so codex
  and opencode pick up the server with no file writes, `lifecycle`
  help topic and tool-description cleanup-duty pointers.
- Lifecycle: orchestrator-spawned children cascade-killed when the
  parent dies; orchestrator-injected prompts end with CR + delayed
  Enter so claude submits cleanly.
2026-05-14 22:04:32 +01:00

224 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}