From bf0dded1d640a9de939b7028f0bbe1a7d44b3c8a Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 13 Jun 2026 12:03:13 +0000 Subject: [PATCH] fix(vterm): replay DEC private modes on attach so resume restores scroll and arrows Reported against v0.2.1: on a session's first launch scroll and arrow keys work, but after detach+re-attach (or resume) they go dead. Root cause, same family as #24 (which replayed SGR colors): the vterm captured screen content but discarded the agent's input-affecting DEC private modes. setPrivateMode treated mouse tracking, bracketed paste, and cursor visibility as "no effect on captured text" and never tracked application cursor keys (DECCKM ?1) or keypad mode at all; Redraw emitted only SGR + content + cursor position. On first attach the agent's mode setup flowed live through the PTY to the attached client, so it worked. On detach the attach client resets those modes; on re-attach the host sends only Redraw(), leaving the client in default mode while the still-running agent expects application-cursor (SS3) arrows and mouse reporting -- so arrows are ignored and the wheel does nothing (v0.2.1's ?1007 change removed the wheel->arrow fallback that had masked it). The vterm now tracks the input/cursor-affecting modes (DECCKM ?1, mouse ?1000/1002/1003 + focus ?1004 + encodings ?1005/1006/1015, bracketed paste ?2004, cursor visibility ?25, application keypad ESC=) and Redraw replays the ones that differ from their power-on default, before the grid content, so a re-attaching or resuming client lands in the agent's expected state. Capture stays plain text; alt-screen and SGR replay are unchanged. Adds unit coverage for each replayed mode (incl. default suppression, toggle-off, RIS reset, alt-screen, ordering) and an end-to-end TestReattachReplaysAgentPrivateModes that detaches and re-attaches to a live host and asserts the modes come back. --- internal/session/session_test.go | 72 ++++++++++++++++++++ internal/vterm/vterm.go | 65 ++++++++++++++++-- internal/vterm/vterm_test.go | 113 +++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 5 deletions(-) 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) + } +}