diff --git a/src/session.ts b/src/session.ts index f86139f5..989a5e1f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 diff --git a/src/web/public/app.js b/src/web/public/app.js index 723d584c..6566d2ab 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -511,7 +511,14 @@ class CodemanApp { this._initGeneration = 0; // dedup concurrent handleInit calls this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive this._selectGeneration = 0; // cancel stale selectSession loads - this._initialFullBufferLoad = true; // first buffer load after a page load fetches full tmux scrollback (COD-47) + // Sessions whose full tmux scrollback has already been replayed this page load + // (COD-47). Tracked PER SESSION rather than as a single "first load" flag: the + // flag was consumed by whichever session auto-selected at page load, so every + // OTHER tab started life with one visible frame of history (issue #205). + this._fullHistoryLoaded = new Set(); + // Cooldown per session for the scroll-to-top "load more history" re-pull. + this._fullHistoryRepullAt = new Map(); // Map + this._fullHistoryRepullInFlight = false; this.terminalLoadStates = new Map(); // Map this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers @@ -4098,6 +4105,58 @@ class CodemanApp { this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); } + /** + * "Load more history": re-pull the whole tmux scrollback when the user scrolls up + * while already at the top of what the browser has. + * + * xterm's buffer is only ever a WINDOW onto tmux's real history, and two things + * shrink it. tmux repaints the pane rectangle instead of emitting linefeeds + * whenever output outpaces its flush interval, which OVERWRITES already-rendered + * scrollback rather than pushing rows into it (measured: a 60-line burst added 1 + * row and destroyed 34, while the same 60 lines emitted slowly added all 60). And + * a tab switch replays only the visible frame. Either way tmux still holds + * everything (history-limit 100k by default), so the fix is to go ask for it with + * the same `?full=1` capture a page reload uses (issue #205). + * + * On demand rather than automatic because that capture is unbounded-ish work: at + * the default history limit it can be megabytes, which is fine to pay when the + * user is explicitly reaching for history and not fine on every tab switch. + */ + async _maybeRefetchFullHistory() { + const sessionId = this.activeSessionId; + if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return; + if (this.detachedSessions?.has(sessionId)) return; + const now = Date.now(); + // Momentum scrolling fires this dozens of times per flick, and a burst of new + // output is the normal reason to want a re-pull, so cooldown rather than latch. + if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < 4000) return; + this._fullHistoryRepullAt.set(sessionId, now); + this._fullHistoryRepullInFlight = true; + try { + const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`); + const buffer = (await res.json())?.data?.terminalBuffer; + // Bail on a tab switch mid-fetch: writing here would paint another session's + // history into the terminal the user is now looking at. + if (!buffer || this.activeSessionId !== sessionId) return; + const rowsBefore = this.terminal.buffer.active.length; + this._resetTerminalForReplay(); + await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId); + if (this.activeSessionId !== sessionId) return; + this.terminalBufferCache.set(sessionId, buffer); + // Hold the user's place. The replay is a superset that grew the buffer + // UPWARD, so what used to be row 0 (what they were looking at) is now `delta` + // rows down; scrolling there reveals the recovered history above it instead + // of teleporting them to the bottom the way a normal buffer load does. + const delta = this.terminal.buffer.active.length - rowsBefore; + if (delta > 0) this.terminal.scrollToLine(delta); + else this.terminal.scrollToTop(); + } catch { + // Transient (offline, 5xx) — the next scroll-up past the cooldown retries. + } finally { + this._fullHistoryRepullInFlight = false; + } + } + _shouldFocusTerminalForTabSwitch() { if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) { return true; @@ -4368,11 +4427,14 @@ class CodemanApp { this._setTerminalLoadState(sessionId, selectGen, 'fetching'); _crashDiag.log('FETCH_START'); - // The FIRST buffer load after a page load requests the full tmux scrollback - // (?full=1, COD-47) so history that scrolled off the server's byte buffer - // comes back after a reload. Tab switches keep the fast ?tail= frame path. - const useFullHistory = this._initialFullBufferLoad === true; - this._initialFullBufferLoad = false; + // The first load OF EACH SESSION this page load requests the full tmux + // scrollback (?full=1, COD-47) so history that scrolled off the server's byte + // buffer comes back. Later switches to an already-replayed session keep the + // fast ?tail= frame path, which is why this is a Set and not a flag: the flag + // version gave the full replay to the auto-selected tab and one frame of + // history to every other one (issue #205). + const useFullHistory = !this._fullHistoryLoaded.has(sessionId); + if (useFullHistory) this._fullHistoryLoaded.add(sessionId); const res = await fetch( useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index ea65fa57..d1af4739 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -429,6 +429,7 @@ Object.assign(CodemanApp.prototype, { } this._noteTerminalUserScroll(lines); this.terminal.scrollLines(lines); + this._maybeLoadMoreHistoryOnScroll(lines); }, { passive: false } ); @@ -453,7 +454,10 @@ Object.assign(CodemanApp.prototype, { if (!isTouching && Math.abs(velocity) > 0.3) { // Momentum phase — convert pixel velocity to lines const lines = Math.round(velocity / cellHeight()); - if (lines !== 0) this.terminal.scrollLines(lines); + if (lines !== 0) { + this.terminal.scrollLines(lines); + this._maybeLoadMoreHistoryOnScroll(lines); + } velocity *= 0.92; scrollFrame = requestAnimationFrame(scrollLoop); } else if (!isTouching) { @@ -516,6 +520,7 @@ Object.assign(CodemanApp.prototype, { if (lines !== 0) { this._noteTerminalUserScroll(lines); this.terminal.scrollLines(lines); + this._maybeLoadMoreHistoryOnScroll(lines); pixelAccum -= lines * ch; } } @@ -2009,6 +2014,20 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the + * buffer while scrolling up is the user reaching for history the browser does + * not have, so pull the rest of tmux's scrollback (issue #205, see + * _maybeRefetchFullHistory). Must be called AFTER scrollLines(), since the + * check is on the resulting position, and it is deliberately not folded into + * _noteTerminalUserScroll for exactly that reason. Cheap: one integer compare + * per scroll event, and the pull itself is cooldown-guarded. + */ + _maybeLoadMoreHistoryOnScroll(lines) { + if (lines >= 0) return; + if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.(); + }, + _hasRecentUserScrollUp() { if (typeof this._lastUserScrollUpAt !== 'number') return false; return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS; @@ -2815,9 +2834,22 @@ Object.assign(CodemanApp.prototype, { // deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through // history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are // unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving. + // + // `deltaMode` says what UNIT the delta is in, and ignoring it made every + // non-pixel browser scroll ~4x too slowly: Firefox reports DOM_DELTA_LINE (1) + // with deltaY≈3 per notch, so the pixel math rounded to 0 and fell through to + // the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In + // Claude mode the same value also capped the forwarded SGR report at one tick. _wheelScrollLines(ev) { const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; - return Math.round(delta / 25) || (delta > 0 ? 1 : -1); + if (!delta) return 0; // pure horizontal swipe: don't fall through to -1 + const lines = + ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel) + ? delta + : ev.deltaMode === 2 // DOM_DELTA_PAGE + ? delta * (this.terminal?.rows || 24) + : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) + return Math.round(lines) || (delta > 0 ? 1 : -1); }, _shouldForwardWheelToApp(ev) { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 5bf58a7a..8d8c8397 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -21,7 +21,7 @@ import { type GeminiConfig, type AntigravityConfig, } from '../../types.js'; -import { Session, isAltScreenStripMode } from '../../session.js'; +import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { SseEvent } from '../sse-events.js'; import { CreateSessionSchema, @@ -1703,6 +1703,11 @@ export function registerSessionRoutes( .replace(ALT_SCREEN_TOGGLE_PATTERN, '') .replace(ERASE_SCROLLBACK_PATTERN, '') .replace(MOUSE_TRACKING_PATTERN, ''); + } else if (isMuxAltScreenOnlyStripMode(session.mode, session.usesMux)) { + // tmux-backed shell/opencode/antigravity: drop tmux's own client smcup only. + // A byte buffer recorded before the live-side strip existed can still carry + // it, and one replayed `\x1b[?1049h` re-parks xterm in the alt buffer (#205). + strippedBuffer = strippedBuffer.replace(ALT_SCREEN_TOGGLE_PATTERN, ''); } if (tailBytes > 0 && strippedBuffer.length > tailBytes) { diff --git a/test/claude-scrollback-strip.test.ts b/test/claude-scrollback-strip.test.ts index 155cc089..513b036e 100644 --- a/test/claude-scrollback-strip.test.ts +++ b/test/claude-scrollback-strip.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Session, isAltScreenStripMode } from '../src/session.js'; +import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../src/session.js'; type SessionInternals = { _handleTerminalOutput(data: string): void; @@ -81,7 +81,7 @@ describe('Claude terminal scrollback strip', () => { }); }); -describe('Shell terminal output is NOT stripped (vim/less/htop need the alt screen)', () => { +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' }); @@ -91,3 +91,59 @@ describe('Shell terminal output is NOT stripped (vim/less/htop need the alt scre 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'); + } + }); +});