fix(tmux): reset capture styles before live output resumes

This commit is contained in:
Ludo Fourrage committed 2026-10-14 18:07:32 +00:00
1 parent a8a8bb71a3
commit 96f6cdea8c
6 files changed
+132 -5

No files matched your search

+1
View File
@@ -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',
+9 -3
View File
@@ -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.
+36
View File
@@ -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<typeof TmuxManager>;
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();
}
}
+2 -2
View File
@@ -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);
+58
View File
@@ -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<ReturnType<typeof createStyledPaneCapture>>;
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('<div id="terminal"></div>');
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<void>((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();
}
});
});
+26
View File
@@ -0,0 +1,26 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { createStyledPaneCapture, styledCapture } from './fixtures/styled-pane-capture.js';
let capture: Awaited<ReturnType<typeof createStyledPaneCapture>>;
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('');
});
});