diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 457df81..324a880 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -519,6 +519,78 @@ func TestAttachDetachDrainsPumpBeforeScreenRestore(t *testing.T) { } } +// 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) { + 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) { diff --git a/internal/vterm/vterm.go b/internal/vterm/vterm.go index 2781c2d..c973055 100644 --- a/internal/vterm/vterm.go +++ b/internal/vterm/vterm.go @@ -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 @@ -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{}, } } @@ -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) } } @@ -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. } } } @@ -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 @@ -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]) { @@ -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 diff --git a/internal/vterm/vterm_test.go b/internal/vterm/vterm_test.go index 4984b5a..546b5fe 100644 --- a/internal/vterm/vterm_test.go +++ b/internal/vterm/vterm_test.go @@ -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) + } +}