mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
fix(terminal): restore shell scrollback, recover history lost to tmux repaints
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
This commit is contained in:
+59
-10
@@ -180,6 +180,37 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
return mode === 'codex' || mode === 'claude' || mode === 'gemini';
|
||||
}
|
||||
|
||||
/**
|
||||
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
|
||||
* and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode`
|
||||
* excludes, but ONLY when the session is tmux-backed (`useMux`).
|
||||
*
|
||||
* The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first
|
||||
* bytes on attach, before any program has run. Unstripped, xterm.js parks in the
|
||||
* alternate buffer for the whole session, where `baseY` is pinned at 0 (no
|
||||
* scrollback to reach, so touch scrolling is a no-op) and xterm's own wheel handler
|
||||
* translates the wheel into `\x1bOA`/`\x1bOB` cursor keys — which readline receives
|
||||
* as shell history navigation. Both reported symptoms, one sequence.
|
||||
*
|
||||
* Why this is safe under tmux, despite the old "shell must keep the alt screen for
|
||||
* vim/less/htop" reasoning: tmux is a full terminal emulator and NEVER forwards a
|
||||
* pane's alt-screen toggles to its client, it repaints instead. Captured from a real
|
||||
* attach, `\x1b[?1049h` appears exactly once (at attach) and vim/less/htop sessions
|
||||
* inside the pane emit zero. So the only thing stripped here is tmux's own smcup.
|
||||
*
|
||||
* Why it is gated on `useMux`: `startShell()`/`startInteractive()` fall back to a
|
||||
* DIRECT PTY when mux creation fails. There the inner program's `\x1b[?1049h` really
|
||||
* does reach xterm, and stripping it would break vim/less/htop for real.
|
||||
*
|
||||
* Why it is narrower than the full strip: with tmux `mouse off`, a mouse-aware
|
||||
* program in the pane (htop, vim with `set mouse=a`) still gets its DECSETs passed
|
||||
* through to the client, so stripping those would break its mouse support. And
|
||||
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
|
||||
*/
|
||||
export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean {
|
||||
return useMux && !isAltScreenStripMode(mode);
|
||||
}
|
||||
|
||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||
|
||||
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
|
||||
@@ -733,6 +764,15 @@ export class Session extends EventEmitter {
|
||||
return this._muxSession?.muxName ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this session's PTY is a tmux client rather than the program itself.
|
||||
* Read by the replay-side alt-screen strip, which must apply the same
|
||||
* `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode).
|
||||
*/
|
||||
get usesMux(): boolean {
|
||||
return this._useMux;
|
||||
}
|
||||
|
||||
get totalCost(): number {
|
||||
return this._totalCost;
|
||||
}
|
||||
@@ -1401,9 +1441,16 @@ export class Session extends EventEmitter {
|
||||
// SSE/WS stream carries them, keeping everything in the main buffer with
|
||||
// scrollback intact. These are controlled TUIs whose cursor-positioned
|
||||
// redraws overwrite only the cells they target, so non-erased rows keep
|
||||
// their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must
|
||||
// keep the alt screen for vim/less/htop.
|
||||
if (isAltScreenStripMode(this.mode)) {
|
||||
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
|
||||
//
|
||||
// Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it
|
||||
// is tmux-backed: alt-screen toggles only, because the sequence that breaks
|
||||
// scrollback there is tmux's own client-side smcup at attach, not anything the
|
||||
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
|
||||
// 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working.
|
||||
const fullStrip = isAltScreenStripMode(this.mode);
|
||||
const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
|
||||
if (fullStrip || altOnlyStrip) {
|
||||
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
||||
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
||||
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
||||
@@ -1419,13 +1466,15 @@ export class Session extends EventEmitter {
|
||||
data = data.slice(0, -splitTail[0].length);
|
||||
if (!data) return;
|
||||
}
|
||||
data = data
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[3J/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
// eslint-disable-next-line no-control-regex
|
||||
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
|
||||
if (fullStrip) {
|
||||
data = data
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[3J/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Scan terminal output for attachment requests. `codeman://attach?...` is an
|
||||
|
||||
Reference in New Issue
Block a user