Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions internal/session/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,78 @@
}
}

// On re-attach the agent's input-affecting private modes (application cursor
// keys, mouse tracking) must come back. The agent sets them live only on its
// first paint; the attach client resets them on detach, so the host's Redraw
// has to replay them or arrows and wheel scroll die on every re-entry.
// Regression test for the resume/re-attach mode-loss bug.
func TestReattachReplaysAgentPrivateModes(t *testing.T) {

Check failure on line 527 in internal/session/session_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_unified-agent-manager&issues=AZ7A30Pt32mQ9-8p31ty&open=AZ7A30Pt32mQ9-8p31ty&pullRequest=28
c := newTestClient(t)
ctx := context.Background()
name := "uam-fake-cccc1111"
// The agent enables application cursor keys + SGR mouse, prints a banner,
// then idles — the modes land in the host's vterm before any attach.
cmd := []string{"/bin/sh", "-c", "printf '\\033[?1h\\033[?1000h\\033[?1006h'; echo banner; sleep 60"}
if err := c.CreateSession(ctx, name, t.TempDir(), nil, cmd); err != nil {
t.Fatalf("CreateSession: %v", err)
}

attachOnce := func() string {
ptmx, tty, err := pty.Open()
if err != nil {
t.Fatal(err)
}
defer func() { _ = ptmx.Close(); _ = tty.Close() }()
done := make(chan error, 1)
go func() { done <- runAttach(c.Dir, name, tty, tty) }()
var mu sync.Mutex
var got []byte
go func() {
buf := make([]byte, 4096)
for {
n, rerr := ptmx.Read(buf)
if n > 0 {
mu.Lock()
got = append(got, buf[:n]...)
mu.Unlock()
}
if rerr != nil {
return
}
}
}()
snapshot := func() string {
mu.Lock()
defer mu.Unlock()
return string(got)
}
waitFor(t, "replay banner", func() bool { return strings.Contains(snapshot(), "banner") })
out := snapshot()
if _, err := ptmx.Write([]byte{0x02, 'd'}); err != nil { // Ctrl+B d
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("runAttach: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("detach chord did not detach")
}
return out
}

if first := attachOnce(); !strings.Contains(first, "banner") {
t.Fatalf("first attach missing banner: %q", first)
}
second := attachOnce()
for _, want := range []string{"\x1b[?1h", "\x1b[?1000h", "\x1b[?1006h"} {
if !strings.Contains(second, want) {
t.Fatalf("re-attach must replay agent private mode %q: %q", want, second)
}
}
}

// The left-arrow quick detach works end to end through the real attach
// client: with nothing typed since attach, a bare left arrow detaches.
func TestAttachLeftArrowDetaches(t *testing.T) {
Expand Down
65 changes: 60 additions & 5 deletions internal/vterm/vterm.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ type Terminal struct {

// cur is the current SGR state; printed cells and BCE fills carry it.
cur attr

// privModes records the on/off state of the input- and cursor-affecting
// DEC private modes in replayModes (application cursor keys, mouse
// tracking, bracketed paste, focus reporting, cursor visibility). The
// agent sets these live on first attach; Redraw replays the active ones
// so a re-attaching client lands in the same state. Modes outside the
// set (and alt-screen, handled by onAlt) are not tracked.
privModes map[int]bool
// appKeypad is DECKPAM (ESC =); the numeric default (ESC >) needs no
// replay. Tracked alongside privModes because screenExit resets it too.
appKeypad bool
}

// bufLine is one captured line: its text and whether it is the soft-wrap
Expand Down Expand Up @@ -79,6 +90,7 @@ func New(cols, rows, maxHistory int) *Terminal {
main: newScreen(cols, rows),
alt: newScreen(cols, rows),
maxHistory: maxHistory,
privModes: map[int]bool{},
}
}

Expand Down Expand Up @@ -189,8 +201,10 @@ func (t *Terminal) stepEsc(r rune) {
t.reverseIndex()
case 'c':
t.reset()
case '=', '>':
// Keypad modes: ignored.
case '=':
t.appKeypad = true // DECKPAM
case '>':
t.appKeypad = false // DECKPNM (numeric, the default)
}
}

Expand Down Expand Up @@ -362,14 +376,29 @@ func csiParams(s string) []int {
return out
}

// replayModes are the input- and cursor-affecting DEC private modes Redraw
// re-emits, in a deterministic order. The agent's attach client tears all of
// these down on detach (mouse + bracketed paste explicitly, DECCKM via
// DECSTR, cursor visibility via ?25h), so without replay a re-attaching
// client is left in default mode while the still-running agent expects them.
var replayModes = []int{1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 2004}

// modeDefaultOn reports a private mode's power-on default: only cursor
// visibility (?25) is on by default, so it is the only mode Redraw replays in
// its reset (l) form; the rest replay only when active.
func modeDefaultOn(p int) bool { return p == 25 }

func (t *Terminal) setPrivateMode(params []int, on bool) {
for _, p := range params {
switch p {
case 47, 1047, 1049:
t.switchAlt(on)
case 25, 2004, 1000, 1002, 1003, 1004, 1005, 1006, 7:
// Cursor visibility, bracketed paste, mouse reporting, autowrap:
// no effect on captured text.
case 1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 2004:
// Input/cursor modes: tracked so Redraw can restore them on
// re-attach (they don't affect captured text). See replayModes.
t.privModes[p] = on
case 7:
// Autowrap: no effect on captured text and not replayed.
}
}
}
Expand Down Expand Up @@ -508,6 +537,8 @@ func (t *Terminal) reset() {
t.alt = newScreen(t.cols, t.rows)
t.state = stGround
t.cur = attr{}
t.privModes = map[int]bool{}
t.appKeypad = false
}

// Resize changes the grid size, preserving as much content as fits. Scroll
Expand Down Expand Up @@ -577,6 +608,7 @@ func (t *Terminal) Redraw() []byte {
s := t.active()
var b strings.Builder
b.WriteString("\x1b[0m\x1b[2J\x1b[H")
t.writeReplayModes(&b)
last := t.rows - 1
for ; last >= 0; last-- {
if !rowBlank(s.cells[last]) {
Expand Down Expand Up @@ -619,6 +651,29 @@ func (t *Terminal) Redraw() []byte {
return []byte(b.String())
}

// writeReplayModes emits the tracked private modes that differ from their
// power-on default, plus application keypad, before the grid content so a
// re-attaching terminal is in the agent's expected state. Alt-screen and SGR
// are handled elsewhere (the attach client owns ?1049; Redraw paints SGR).
func (t *Terminal) writeReplayModes(b *strings.Builder) {
for _, p := range replayModes {
on, seen := t.privModes[p]
if !seen || on == modeDefaultOn(p) {
continue
}
b.WriteString("\x1b[?")
b.WriteString(strconv.Itoa(p))
if on {
b.WriteByte('h')
} else {
b.WriteByte('l')
}
}
if t.appKeypad {
b.WriteString("\x1b=")
}
}

// screen is one character grid (main or alternate).
type screen struct {
cells [][]cell
Expand Down
113 changes: 113 additions & 0 deletions internal/vterm/vterm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,116 @@ func TestEraseLineUsesCurrentBackground(t *testing.T) {
t.Fatalf("EL must fill with current bg: %q", out)
}
}

// A re-attaching client gets a fresh Redraw; the agent's input-affecting DEC
// private modes are set live only on the first attach, so Redraw must replay
// the ones still active or arrows (application cursor keys) and wheel scroll
// (mouse reporting) break on every re-attach.
func TestRedrawReplaysApplicationCursorKeys(t *testing.T) {
term := New(20, 3, 0)
feed(t, term, "\x1b[?1h") // DECCKM on
if out := string(term.Redraw()); !strings.Contains(out, "\x1b[?1h") {
t.Fatalf("Redraw must replay application cursor keys: %q", out)
}
}

func TestRedrawReplaysMouseModes(t *testing.T) {
term := New(20, 3, 0)
feed(t, term, "\x1b[?1000;1002;1003;1006h") // tracking + SGR encoding
out := string(term.Redraw())
for _, want := range []string{"\x1b[?1000h", "\x1b[?1002h", "\x1b[?1003h", "\x1b[?1006h"} {
if !strings.Contains(out, want) {
t.Fatalf("Redraw must replay mouse mode %q: %q", want, out)
}
}
}

func TestRedrawReplaysBracketedPasteAndFocus(t *testing.T) {
term := New(20, 3, 0)
feed(t, term, "\x1b[?2004h\x1b[?1004h")
out := string(term.Redraw())
if !strings.Contains(out, "\x1b[?2004h") || !strings.Contains(out, "\x1b[?1004h") {
t.Fatalf("Redraw must replay bracketed paste and focus reporting: %q", out)
}
}

func TestRedrawReplaysHiddenCursor(t *testing.T) {
term := New(20, 3, 0)
feed(t, term, "\x1b[?25l") // cursor hidden by the agent
if out := string(term.Redraw()); !strings.Contains(out, "\x1b[?25l") {
t.Fatalf("Redraw must replay a hidden cursor: %q", out)
}
// A visible cursor is the terminal default; do not emit a redundant ?25h.
vis := New(20, 3, 0)
feed(t, vis, "\x1b[?25l\x1b[?25h")
if out := string(vis.Redraw()); strings.Contains(out, "\x1b[?25") {
t.Fatalf("Redraw must not emit cursor-visibility at the default: %q", out)
}
}

func TestRedrawReplaysApplicationKeypad(t *testing.T) {
term := New(20, 3, 0)
feed(t, term, "\x1b=") // DECKPAM
if out := string(term.Redraw()); !strings.Contains(out, "\x1b=") {
t.Fatalf("Redraw must replay application keypad: %q", out)
}
num := New(20, 3, 0)
feed(t, num, "\x1b=\x1b>") // app then back to numeric
if out := string(num.Redraw()); strings.Contains(out, "\x1b=") {
t.Fatalf("Redraw must not replay keypad at the numeric default: %q", out)
}
}

func TestRedrawOmitsModesAtTheirDefault(t *testing.T) {
term := New(20, 3, 0)
feed(t, term, "hello")
out := string(term.Redraw())
for _, none := range []string{"\x1b[?1h", "\x1b[?1000h", "\x1b[?1006h", "\x1b[?2004h", "\x1b[?1004h", "\x1b[?25l", "\x1b="} {
if strings.Contains(out, none) {
t.Fatalf("fresh terminal must not replay default-off mode %q: %q", none, out)
}
}
}

func TestRedrawDropsModesTurnedBackOff(t *testing.T) {
term := New(20, 3, 0)
feed(t, term, "\x1b[?1h\x1b[?1000h\x1b[?1l\x1b[?1000l") // on then off
out := string(term.Redraw())
if strings.Contains(out, "\x1b[?1h") || strings.Contains(out, "\x1b[?1000h") {
t.Fatalf("Redraw must not replay modes the agent turned back off: %q", out)
}
}

func TestResetClearsReplayedModes(t *testing.T) {
term := New(20, 3, 0)
feed(t, term, "\x1b[?1h\x1b[?1000h\x1b=")
feed(t, term, "\x1bc") // RIS full reset
out := string(term.Redraw())
if strings.Contains(out, "\x1b[?1h") || strings.Contains(out, "\x1b[?1000h") || strings.Contains(out, "\x1b=") {
t.Fatalf("RIS must clear tracked modes: %q", out)
}
}

// Modes are global to the terminal, not per-screen: a full-screen TUI sets
// DECCKM after entering its alternate screen, and Redraw must still replay it
// once the agent is back (or while it is on either screen).
func TestRedrawReplaysModesSetOnAltScreen(t *testing.T) {
term := New(20, 3, 0)
feed(t, term, "\x1b[?1049h\x1b[?1h\x1b[?1000h") // enter alt, then set modes
if out := string(term.Redraw()); !strings.Contains(out, "\x1b[?1h") || !strings.Contains(out, "\x1b[?1000h") {
t.Fatalf("modes set on the alt screen must still replay: %q", out)
}
}

// The replayed modes must precede the grid content so the terminal is in the
// right state before the cursor is parked and the agent's next output lands.
func TestRedrawModesPrecedeContent(t *testing.T) {
term := New(20, 3, 0)
feed(t, term, "\x1b[?1hHI")
out := string(term.Redraw())
mode := strings.Index(out, "\x1b[?1h")
content := strings.Index(out, "HI")
if mode < 0 || content < 0 || mode > content {
t.Fatalf("mode replay must come before content (mode=%d content=%d): %q", mode, content, out)
}
}
Loading