This batches the in-flight [Unreleased] block from CHANGELOG.md into a single commit. Highlights: - Real MCP protocol layer (initialize / tools/list / tools/call) so vendor MCP clients can complete the handshake against the per-PID socket. Legacy direct-dispatch preserved for the harness. - New mcp_injection kinds — cli_override for codex, config_env for opencode — joining the existing env-var and config_file paths so patterm can slot into more agents without touching their real config or auth. - Ctrl+A/D and Ctrl+W/S focus navigation across tabs and intra-tab process lists, recognised in legacy / kitty CSI u / xterm modifyOtherKeys encodings. - Palette macros (sw / k / sp ) and reordering so open sessions surface above spawn-new entries. - Two-row tab bar, sidebar/tabbar/status chrome cache, viewport-wipe on agent spawn, CR-terminated orchestrator injections, and split- Enter PTY writes so paste-detecting TUIs see Enter as a key event. Also fixes the bug logged in TODO: claude's Ctrl+O tool-call expansion emits CSI 0 J, which the viewport renderer was forwarding verbatim — wiping the sidebar to the right of the cursor and leaving the chrome cache convinced nothing had changed. CSI 0 J and CSI 1 J are now translated into per-row ECH sequences clamped to the viewport, same as CSI 2 J and CSI K already were. Agent guides (CLAUDE.md / AGENTS.md) now spell out the TODO->CHANGELOG workflow so completed items land in the changelog rather than as ticked entries left behind in TODO.
105 lines
3.7 KiB
Go
105 lines
3.7 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)
|
|
}
|
|
}
|