From 96f6cdea8cacb1ee767676bed602b6ac243b8563 Mon Sep 17 00:00:00 2001 From: Ludo Fourrage Date: Wed, 14 Oct 2026 18:07:32 +0000 Subject: [PATCH] fix(tmux): reset capture styles before live output resumes --- config/test-suites.ts | 1 + src/tmux-manager.ts | 12 ++++-- test/fixtures/styled-pane-capture.ts | 36 ++++++++++++++++ test/tmux-capture-full-history.test.ts | 4 +- test/tmux-capture-sgr.browser.test.ts | 58 ++++++++++++++++++++++++++ test/tmux-capture-sgr.test.ts | 26 ++++++++++++ 6 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/styled-pane-capture.ts create mode 100644 test/tmux-capture-sgr.browser.test.ts create mode 100644 test/tmux-capture-sgr.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index 0a36d96f..2148a467 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -19,6 +19,7 @@ * why these are a runnable suite (`npm run test:browser`) rather than skipped. */ export const BROWSER_TEST_GLOBS = [ + 'test/tmux-capture-sgr.browser.test.ts', 'test/tab-rail-resize.browser.test.ts', 'test/tab-activation.browser.test.ts', 'test/tab-layout-editing.browser.test.ts', diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index cd0d07c1..808e511a 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -4076,7 +4076,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Without geometry there is no cursor move, so fall back to the old trim. // Keeping the blank rows here would park the caret at the bottom of the // pane with nothing to correct it — worse than not trying at all. - if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\n+$/g, '')); + if (!geometry) { + const trimmed = rawCapture.replace(/\n+$/g, ''); + return trimmed ? `${normalizeScrollbackEol(trimmed)}\x1b[0m` : ''; + } // Take the line terminator off and nothing else. The trailing blank rows // that remain are the real bottom of the screen, and the cursor move // below counts up from it. tmux joins rows with a bare `\n`; normalize to @@ -4090,7 +4093,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // `_replayWouldShrinkBuffer` exists to refuse, arriving from the server // side where that guard cannot see it. if (!hasVisibleContent(trimmed)) return ''; - return `${normalizeScrollbackEol(trimmed)}${formatCursorRestore(geometry)}`; + // capture-pane -e can leave the last row's SGR active (e.g. a nested + // tmux status bar). Hand live output the same default style as a visible + // snapshot, resetting before the cursor move so its position is kept. + return `${normalizeScrollbackEol(trimmed)}\x1b[0m${formatCursorRestore(geometry)}`; } const buffer = rawCapture.replace(/\n+$/g, ''); @@ -4099,7 +4105,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // positioned snapshot repaint and fall back to the raw capture. Normalize // its bare `\n` line endings to `\r\n` so the replay doesn't staircase // diagonally in a fresh xterm (COD-138, same reason as the fullHistory path). - return normalizeScrollbackEol(buffer); + return buffer ? `${normalizeScrollbackEol(buffer)}\x1b[0m` : ''; } catch (err) { // ENOBUFS carries the truncated multi-MB stdout on the error object — // log a concise line instead of dumping it into the journal. diff --git a/test/fixtures/styled-pane-capture.ts b/test/fixtures/styled-pane-capture.ts new file mode 100644 index 00000000..95a98ecb --- /dev/null +++ b/test/fixtures/styled-pane-capture.ts @@ -0,0 +1,36 @@ +import { vi } from 'vitest'; + +export const styledCapture = 'normal\n\x1b[30m\x1b[42m[session] 0:claude*\n'; + +// Exercise the real capture method without a constructor or any real tmux IO. +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), + execFileSync: vi.fn(), + exec: vi.fn(), + execFile: vi.fn(), + spawn: vi.fn(), +})); + +export async function createStyledPaneCapture() { + vi.resetModules(); + vi.stubEnv('VITEST', undefined); + try { + const { TmuxManager } = await import('../../src/tmux-manager.js'); + const { execSync } = await import('node:child_process'); + const manager = Object.create(TmuxManager.prototype) as InstanceType; + Object.defineProperty(manager, 'tmux', { value: () => 'fake-tmux' }); + return (fullHistory: boolean, geometry = true, capture = styledCapture) => { + vi.mocked(execSync).mockImplementation((command) => { + if (String(command).includes('capture-pane')) return capture; + if (String(command).includes('display-message')) { + if (!geometry) throw new Error('cursor query failed'); + return '0 0 40 2 0 1'; + } + throw new Error(`Unexpected tmux command: ${command}`); + }); + return manager.capturePaneBuffer('codeman-629', undefined, { fullHistory }); + }; + } finally { + vi.unstubAllEnvs(); + } +} diff --git a/test/tmux-capture-full-history.test.ts b/test/tmux-capture-full-history.test.ts index b1b3db7f..9ed86ea3 100644 --- a/test/tmux-capture-full-history.test.ts +++ b/test/tmux-capture-full-history.test.ts @@ -80,7 +80,7 @@ describe('tmux full-history pane capture (COD-47)', () => { // up from them, so the two decisions travel together: no geometry, no move, // and the old trim applies instead. expect(methodBody).toContain("rawCapture.replace(/\\n$/, '')"); - expect(methodBody).toContain("if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\\n+$/g, ''))"); + expect(methodBody).toContain("const trimmed = rawCapture.replace(/\\n+$/g, '')"); }); it('defers to the byte history when the pane holds nothing visible', () => { @@ -144,7 +144,7 @@ describe('the geometry a capture reports back', () => { const write = methodBody.indexOf('opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows }'); // Anchor on the REPLAY branch, not the earlier `if (fullHistory)` that only // sizes the exec buffer. - const replayBranch = methodBody.indexOf('if (!geometry) return normalizeScrollbackEol('); + const replayBranch = methodBody.indexOf('if (!geometry) {'); const visibleReturn = methodBody.indexOf('if (geometry) return formatPaneSnapshot('); expect(write).toBeGreaterThan(-1); expect(replayBranch).toBeGreaterThan(-1); diff --git a/test/tmux-capture-sgr.browser.test.ts b/test/tmux-capture-sgr.browser.test.ts new file mode 100644 index 00000000..f6119916 --- /dev/null +++ b/test/tmux-capture-sgr.browser.test.ts @@ -0,0 +1,58 @@ +import { createRequire } from 'node:module'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser } from 'playwright'; +import { createStyledPaneCapture } from './fixtures/styled-pane-capture.js'; + +let browser: Browser; +let capture: Awaited>; +beforeAll(async () => { + capture = await createStyledPaneCapture(); + browser = await chromium.launch({ headless: true }); +}); +afterAll(async () => { + await browser?.close(); +}); + +describe('captured status-bar colors stay out of live writes (#629)', () => { + it.each([ + [true, true], + [true, false], + [false, false], + ])('fullHistory=%s, geometry=%s', async (fullHistory, geometry) => { + const snapshot = capture(fullHistory, geometry); + expect(snapshot).not.toBeNull(); + const page = await browser.newPage(); + try { + await page.setContent('
'); + await page.addScriptTag({ path: createRequire(import.meta.url).resolve('@xterm/xterm') }); + const result = await page.evaluate(async (snapshot) => { + const terminal = new (window as any).Terminal({ cols: 40, rows: 2 }); + terminal.open(document.getElementById('terminal')); + const write = (data: string) => new Promise((resolve) => terminal.write(data, resolve)); + await write(snapshot!); + const status = terminal.buffer.active.getLine(1).getCell(0); + const statusBackground = status.getBgColor(); + await write('\x1b[1;1Htyped\x1b[K'); + const line = terminal.buffer.active.getLine(0); + const result = { + statusBackground, + text: line.translateToString(true), + typedDefault: line.getCell(0).isBgDefault(), + erasedDefault: line.getCell(10).isBgDefault(), + statusAfter: terminal.buffer.active.getLine(1).getCell(0).getBgColor(), + }; + terminal.dispose(); + return result; + }, snapshot); + expect(result).toEqual({ + statusBackground: 2, + text: 'typed', + typedDefault: true, + erasedDefault: true, + statusAfter: 2, + }); + } finally { + await page.close(); + } + }); +}); diff --git a/test/tmux-capture-sgr.test.ts b/test/tmux-capture-sgr.test.ts new file mode 100644 index 00000000..8e283ebe --- /dev/null +++ b/test/tmux-capture-sgr.test.ts @@ -0,0 +1,26 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { createStyledPaneCapture, styledCapture } from './fixtures/styled-pane-capture.js'; + +let capture: Awaited>; +beforeAll(async () => { + capture = await createStyledPaneCapture(); +}); + +describe('capturePaneBuffer style handoff (#629)', () => { + it('resets the captured style before restoring the cursor', () => { + expect(capture(true)).toBe(`${styledCapture.trimEnd().replace(/\n/g, '\r\n')}\x1b[0m\x1b[1A\r`); + }); + + it.each([true, false])('resets the raw fallback (fullHistory=%s)', (fullHistory) => { + expect(capture(fullHistory, false)).toBe(`${styledCapture.trimEnd().replace(/\n/g, '\r\n')}\x1b[0m`); + }); + + it.each([true, false])('keeps an empty raw fallback empty (fullHistory=%s)', (fullHistory) => { + expect(capture(fullHistory, false, '\n\n')).toBe(''); + expect(capture(fullHistory, false, '')).toBe(''); + }); + + it('keeps an all-blank full-history capture empty', () => { + expect(capture(true, true, '\n\n')).toBe(''); + }); +});