mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #632 from ludovicfourrage/fix/capture-pane-sgr-reset-629
fix(tmux): reset captured SGR before live output resumes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c5417abd27
6 files changed
+132
-5
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
Vendored
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user