mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Four fixes for the scrollback reports in #205 (plus its follow-up comment). 1. tmux-backed shell/opencode/antigravity sessions were parked in xterm's ALTERNATE buffer for their whole life. The tmux CLIENT emits smcup (\x1b[?1049h) as its first bytes on attach, and the existing strip is gated to claude/codex/gemini, so it reached the browser verbatim. In the alternate buffer baseY is pinned at 0 (no scrollback, so touch scrolling is a no-op) and xterm's own wheel handler translates the wheel into \x1bOA cursor keys, which readline receives as shell history navigation. Both reported symptoms, one sequence. isMuxAltScreenOnlyStripMode() now strips that toggle for those modes, but ONLY under tmux (the direct-PTY fallback still needs a program's own alt screen) and ONLY the alt-screen toggle: 3J from a user's `clear` and the mouse DECSETs a pane's htop/vim rely on are left alone. Safe because tmux never forwards a pane's alt-screen toggles to its client, it repaints; captured from a real attach, vim/less/htop emit zero. 2. "Load more history" on scroll-to-top. xterm's buffer is only ever a window onto tmux's history, and tmux repaints the pane rectangle instead of emitting linefeeds whenever output outpaces its flush, OVERWRITING already-rendered scrollback. Measured: a 60-line burst added 1 row and destroyed 34, while the same 60 lines emitted slowly added all 60. Scrolling up at the top now re-pulls the full tmux scrollback and holds the user's place. Verified end to end: 42 rendered rows -> 213, recovering all 150+60 printed lines. 3. The full-scrollback replay was gated on a single "first load after page load" flag, which whichever session auto-selected consumed, so every other tab started with one visible frame. Now tracked per session. 4. _wheelScrollLines ignored ev.deltaMode, so Firefox (DOM_DELTA_LINE, deltaY 3 per notch) scrolled one line where Chrome scrolls four or five, and capped the forwarded SGR report at one tick. Line and page deltas are now converted, and a pure horizontal swipe no longer falls through to a phantom -1. Analysis and measurements: docs/scrollback-issues-analysis.md
150 lines
6.0 KiB
TypeScript
150 lines
6.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../src/session.js';
|
||
|
||
type SessionInternals = {
|
||
_handleTerminalOutput(data: string): void;
|
||
};
|
||
|
||
function handleOutput(session: Session, data: string): void {
|
||
(session as unknown as SessionInternals)._handleTerminalOutput(data);
|
||
}
|
||
|
||
describe('isAltScreenStripMode', () => {
|
||
it('strips for the controlled TUIs (codex + claude), not shell/opencode', () => {
|
||
expect(isAltScreenStripMode('codex')).toBe(true);
|
||
expect(isAltScreenStripMode('claude')).toBe(true);
|
||
expect(isAltScreenStripMode('shell')).toBe(false);
|
||
expect(isAltScreenStripMode('opencode')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('Claude terminal scrollback strip', () => {
|
||
it('strips alt-screen toggles, scrollback-erase, and mouse-tracking', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
const emitted: string[] = [];
|
||
session.on('terminal', (data) => emitted.push(data));
|
||
|
||
handleOutput(session, '\x1b[?1049h\x1b[55;1Hdialog\x1b[3J\x1b[?1006h\x1b[?1049l');
|
||
|
||
expect(emitted[0]).toBe('\x1b[55;1Hdialog');
|
||
expect(session.terminalBuffer).toBe('\x1b[55;1Hdialog');
|
||
});
|
||
|
||
it('keeps the visible-screen erase (2J / [J) — only scrollback-erase (3J) is dropped', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
|
||
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
|
||
|
||
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
|
||
});
|
||
|
||
it('preserves an ordinary erase-display redraw (no scrollback sequences)', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
|
||
handleOutput(session, '\x1b[H\x1b[Jclaude redraw');
|
||
|
||
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jclaude redraw');
|
||
});
|
||
|
||
it('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
const emitted: string[] = [];
|
||
session.on('terminal', (data) => emitted.push(data));
|
||
|
||
handleOutput(session, 'before\x1b[?104');
|
||
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
|
||
handleOutput(session, 'Jtail');
|
||
|
||
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
|
||
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
|
||
});
|
||
|
||
it('emits nothing for a chunk that is only a partial CSI, then completes it', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
const emitted: string[] = [];
|
||
session.on('terminal', (data) => emitted.push(data));
|
||
|
||
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
|
||
handleOutput(session, '6h done'); // completes ?1006h (stripped); rest passes
|
||
|
||
expect(emitted).toEqual([' done']);
|
||
expect(session.terminalBuffer).toBe(' done');
|
||
});
|
||
|
||
it('does not touch ordinary Claude conversation output', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
|
||
const text = 'Here is line one\r\nHere is line two\r\n\x1b[2mdim status\x1b[0m';
|
||
handleOutput(session, text);
|
||
|
||
expect(session.terminalBuffer).toBe(text);
|
||
});
|
||
});
|
||
|
||
describe('Shell terminal output on a DIRECT PTY is NOT stripped (vim/less/htop need the alt screen)', () => {
|
||
it('leaves alt-screen toggles, scrollback-erase, and mouse-tracking intact for shell', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||
|
||
const vimLike = '\x1b[?1049h\x1b[?1002h\x1b[2J~ editing\x1b[3J\x1b[?1002l\x1b[?1049l';
|
||
handleOutput(session, vimLike);
|
||
|
||
expect(session.terminalBuffer).toBe(vimLike);
|
||
});
|
||
});
|
||
|
||
describe('isMuxAltScreenOnlyStripMode', () => {
|
||
it('covers exactly the modes the full strip does not, and only under tmux', () => {
|
||
for (const mode of ['shell', 'opencode', 'antigravity'] as const) {
|
||
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true);
|
||
// Direct-PTY fallback: the program's own alt screen really does reach xterm.
|
||
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
|
||
}
|
||
// The full strip already owns these; never double-gate them here.
|
||
for (const mode of ['claude', 'codex', 'gemini'] as const) {
|
||
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('tmux-backed shell: strip tmux’s own client smcup, keep everything else (#205)', () => {
|
||
it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||
|
||
// What a real `tmux attach` emits as its first bytes.
|
||
handleOutput(session, '\x1b[?1049h\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2Jprompt$ ');
|
||
|
||
expect(session.terminalBuffer).toBe('\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2Jprompt$ ');
|
||
expect(session.terminalBuffer).not.toContain('\x1b[?1049h');
|
||
});
|
||
|
||
it('KEEPS 3J and mouse-tracking, unlike the full strip', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||
|
||
// `clear` legitimately wipes scrollback; htop/vim mouse modes are passed
|
||
// through by tmux even with `mouse off` and must keep working.
|
||
handleOutput(session, '\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l');
|
||
|
||
expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l');
|
||
});
|
||
|
||
it('reassembles alt-screen sequences split across PTY chunk boundaries', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||
const emitted: string[] = [];
|
||
session.on('terminal', (data) => emitted.push(data));
|
||
|
||
handleOutput(session, 'before\x1b[?104');
|
||
handleOutput(session, '9h after');
|
||
|
||
expect(session.terminalBuffer).toBe('before after');
|
||
expect(emitted).toEqual(['before', ' after']);
|
||
});
|
||
|
||
it('applies to opencode and antigravity too', () => {
|
||
for (const mode of ['opencode', 'antigravity'] as const) {
|
||
const session = new Session({ workingDir: '/tmp', mode, useMux: true });
|
||
handleOutput(session, '\x1b[?1049hTUI\x1b[3J');
|
||
expect(session.terminalBuffer).toBe('TUI\x1b[3J');
|
||
}
|
||
});
|
||
});
|