mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
feat(terminal): tmux pane-buffer primitives and session/render reliability
Mode-agnostic terminal foundation extracted from the downstream branch: - formatPaneSnapshot: SGR/grapheme-aware tmux pane capture + active-pane resolution, with OSC/CSI redraw suppression and the buffer-load owner-token race fix on the terminal fetch path - socket-correct tmux lifecycle: dedicated -L socket and /tmp launch cwd in createSession (restores the FUSE/getcwd hardening from #110), and a socket-aware re-attach window-size query (avoids the 120x40 flicker) - inline-rename: commit/cancel state handling clears _activeRename and skips the API call on cancel - selectSession: restored detached-window raise short-circuit The codex-specific xterm snapshot/replay, the vendored serialize addon, and the synchronous live pane-capture on the request path are intentionally excluded: they depend on a 'codex' SessionMode that doesn't exist on master and are deferred to COD-34 (which introduces that mode). The capture primitives remain exported for COD-34 to build on. Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
This commit is contained in:
co-authored by
Saqeb Akhter
parent
2952256d65
commit
0569f68b86
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function cssRuleBody(css: string, selector: string): string {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
|
||||
describe('terminal layout CSS', () => {
|
||||
it('pins the xterm root to the terminal container width while scrollback moves', () => {
|
||||
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||
const xtermRule = cssRuleBody(css, '.terminal-container .xterm');
|
||||
|
||||
expect(xtermRule).toContain('width: 100%');
|
||||
expect(xtermRule).toContain('min-width: 0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('tmux styled pane capture', () => {
|
||||
it('requests SGR style escapes for canonical browser replay frames', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||
const captureMethod = source.indexOf('capturePaneBuffer(muxName: string');
|
||||
const captureCommand = source.indexOf('capture-pane -p', captureMethod);
|
||||
|
||||
expect(captureMethod).toBeGreaterThan(-1);
|
||||
expect(captureCommand).toBeGreaterThan(captureMethod);
|
||||
expect(source.slice(captureCommand, captureCommand + 80)).toContain('capture-pane -p -e');
|
||||
});
|
||||
});
|
||||
+102
-1
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TmuxManager, parsePaneList } from '../src/tmux-manager.js';
|
||||
import { TmuxManager, formatPaneSnapshot, parsePaneList, resolveActivePaneTarget } from '../src/tmux-manager.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
// ============================================================================
|
||||
@@ -118,6 +118,107 @@ describe('TmuxManager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('window sizing', () => {
|
||||
it('pins a tmux window to manual sizing before browser attach', () => {
|
||||
expect(manager.setManualWindowSize('codeman-abc12345')).toBe(true);
|
||||
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
"tmux -L 'codeman' set-window-option -t 'codeman-abc12345' window-size manual",
|
||||
expect.objectContaining({ stdio: 'ignore' })
|
||||
);
|
||||
});
|
||||
|
||||
it('resizes the tmux window when Codeman accepts a desktop resize', () => {
|
||||
expect(manager.resizeWindow('codeman-abc12345', 140, 42)).toBe(true);
|
||||
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
"tmux -L 'codeman' resize-window -t 'codeman-abc12345' -x 140 -y 42",
|
||||
expect.objectContaining({ stdio: 'ignore' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment exports', () => {
|
||||
it('keeps COLORTERM unset for OpenCode sessions', () => {
|
||||
const exports = (
|
||||
manager as unknown as {
|
||||
buildEnvExports(sessionId: string, muxName: string, mode: string): string[];
|
||||
}
|
||||
).buildEnvExports('session-1', 'codeman-abc12345', 'opencode');
|
||||
|
||||
expect(exports).toContain('unset COLORTERM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPaneSnapshot', () => {
|
||||
it('paints captured rows with absolute cursor positions to avoid newline autowrap scroll', () => {
|
||||
const fullWidthLine = 'x'.repeat(10);
|
||||
|
||||
const snapshot = formatPaneSnapshot([fullWidthLine, 'next line'], {
|
||||
cols: 10,
|
||||
rows: 4,
|
||||
cursorX: 2,
|
||||
cursorY: 1,
|
||||
});
|
||||
|
||||
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(9)}\x1b[2;1Hnext line\x1b[2;3H`);
|
||||
expect(snapshot).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('preserves SGR color while stripping non-style pane controls', () => {
|
||||
const snapshot = formatPaneSnapshot(['\x1b[32mgreen\x1b[0m\x1b[2K\x1b[10;20Htail'], {
|
||||
cols: 40,
|
||||
rows: 2,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
});
|
||||
|
||||
expect(snapshot).toContain('\x1b[32mgreen\x1b[0m');
|
||||
expect(snapshot).toContain('tail');
|
||||
expect(snapshot).not.toContain('\x1b[2K');
|
||||
expect(snapshot).not.toContain('\x1b[10;20H');
|
||||
});
|
||||
|
||||
it('truncates styled rows by visible columns without cutting SGR escapes', () => {
|
||||
const snapshot = formatPaneSnapshot(['\x1b[31mabcdef\x1b[0m'], {
|
||||
cols: 4,
|
||||
rows: 1,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
});
|
||||
|
||||
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabc\x1b[0m\x1b[1;1H');
|
||||
});
|
||||
|
||||
it('does not let full-width glyphs cross the paint boundary', () => {
|
||||
const snapshot = formatPaneSnapshot(['abc\u754cdef'], {
|
||||
cols: 5,
|
||||
rows: 1,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
});
|
||||
|
||||
expect(snapshot).toBe('\x1b[1;1Habc\x1b[1;1H');
|
||||
});
|
||||
|
||||
it('keeps combining marks attached without consuming a terminal column', () => {
|
||||
const snapshot = formatPaneSnapshot(['a\u0301bc'], {
|
||||
cols: 4,
|
||||
rows: 1,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
});
|
||||
|
||||
expect(snapshot).toBe('\x1b[1;1Ha\u0301bc\x1b[1;1H');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveActivePaneTarget', () => {
|
||||
it('selects the active pane instead of assuming pane zero', () => {
|
||||
expect(resolveActivePaneTarget('%1:0\n%18:1\n')).toBe('%18');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('should return true when tmux is found', () => {
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
|
||||
Reference in New Issue
Block a user