Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 9 additions & 3 deletions src/tmux-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, '');
Expand All @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions test/fixtures/styled-pane-capture.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
4 changes: 2 additions & 2 deletions test/tmux-capture-full-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 58 additions & 0 deletions test/tmux-capture-sgr.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions test/tmux-capture-sgr.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});