From d571c41fb11cacbb33d9307ddb43e58950874412 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 13 Oct 2026 18:01:45 +0200 Subject: [PATCH] fix(remote): reset SGR at the remote scrollback splice seam (#602 merge-time fix) joinScrollbackAndFrame joins two separate `capture-pane -e` outputs. The remote history's last row can end with attributes still active (a diff row's background, dim tool output), while the local frame is encoded relative to the default state, so its first plain rows inherited them in the browser. A `\x1b[0m` now ends the scrollback before the frame. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tmux-manager.ts | 4 +++- test/remote-pane-history.test.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 5ca52921..c1122547 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -701,7 +701,9 @@ export function normalizeScrollbackEol(buffer: string): string { */ export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string): string { if (!scrollback) return frame; - return `${scrollback.replace(/\n$/, '')}\n${frame}`; + // Two separate captures: tmux encodes the frame relative to the DEFAULT + // state, so the scrollback's last row must not leave its attributes active. + return `${scrollback.replace(/\n$/, '')}\x1b[0m\n${frame}`; } /** Pane geometry and caret position, as `display-message` reports them. */ diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 144df6cc..4b5ca3f8 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -146,8 +146,15 @@ describe('the remote script under a real shell', () => { describe('joinScrollbackAndFrame', () => { it('puts the scrollback rows above the frame in tmux’s own row shape', () => { - expect(joinScrollbackAndFrame('old 1\nold 2\n', 'frame 1\nframe 2\n')).toBe('old 1\nold 2\nframe 1\nframe 2\n'); - expect(joinScrollbackAndFrame('old 1', 'frame 1')).toBe('old 1\nframe 1'); + expect(joinScrollbackAndFrame('old 1\nold 2\n', 'frame 1\nframe 2\n')).toBe( + 'old 1\nold 2\x1b[0m\nframe 1\nframe 2\n' + ); + expect(joinScrollbackAndFrame('old 1', 'frame 1')).toBe('old 1\x1b[0m\nframe 1'); + }); + + it('resets SGR at the seam, so the last history row cannot bleed into the frame', () => { + // A remote history row that ends with a background still set (a diff row). + expect(joinScrollbackAndFrame('\x1b[41mRED ROW\n', 'plain\n')).toBe('\x1b[41mRED ROW\x1b[0m\nplain\n'); }); it('no scrollback leaves the frame alone', () => {