From eb8d11ffc3a2af62b518ad97b80329a1f0afb591 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 04:06:54 +0200 Subject: [PATCH 1/9] 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 --- src/session.ts | 69 ++++++++++++++++++++++---- src/web/public/app.js | 74 +++++++++++++++++++++++++--- src/web/public/terminal-ui.js | 36 +++++++++++++- src/web/routes/session-routes.ts | 7 ++- test/claude-scrollback-strip.test.ts | 60 +++++++++++++++++++++- 5 files changed, 225 insertions(+), 21 deletions(-) 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'); + } + }); +}); From adbb74cd5acc7a4b17e7af82962c3a161872c460 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 04:27:16 +0200 Subject: [PATCH 2/9] fix(terminal): keep the CLI's input box pinned when scrolling with the wheel Reported against the beta: scrolling up in a Claude session drags the prompt box and status line up the screen along with everything else, and only once the local buffer hits its top does the CLI's own history start moving. _shouldForwardWheelToApp() gated forwarding on the viewport being at the buffer bottom, so that leaving the bottom handed the wheel back to local scrollback and both histories stayed reachable. Two things make that the wrong default: - A repaint-mode CLI keeps no terminal scrollback of its own (tmux reports history_size=0 for a Claude pane), so xterm's buffer holds only Codeman's REPLAYED repaint frames. Scrolling those locally moves the CLI's pinned furniture and shows stale frames underneath. - scrollToLastNonEmptyLine() parks the viewport `rows - 2` above the last non-empty row, so any session with trailing blank rows was left off-bottom and every later wheel event went local without the user ever scrolling. Forward unconditionally for the verified modes instead, and snap the viewport back to the bottom before encoding the report (SGR coordinates address the live screen, and forwarding while the user stares at stale scrollback looks dead). Shift+wheel and the "Wheel scrolls local history" opt-out still reach local scrollback. Verified against a real Claude 2.1.223 session: wheel-up scrolls its transcript back 48 lines (rows showing 85-92 -> 37-44) while the input box, separator and status line stay fixed at the bottom. --- src/web/public/terminal-ui.js | 30 ++++++++++++++++++++++++++---- test/terminal-touch-tap.test.ts | 30 +++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d1af4739..449178c5 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -415,15 +415,21 @@ Object.assign(CodemanApp.prototype, { // ignores wheel reports); older versions DO capture wheel as option // navigation, so they keep the local wheel. // Shift+wheel always scrolls xterm's local scrollback (Codeman's restored - // history lives there), and once the viewport left the bottom the wheel - // stays local until the user scrolls back down — so both scrollbacks stay - // reachable without a mode switch. + // history lives there); the plain wheel stays on the CLI's transcript for + // those modes regardless of scroll position, so the CLI's input box never + // slides off the screen (see _shouldForwardWheelToApp). container.addEventListener( 'wheel', (ev) => { ev.preventDefault(); const lines = this._wheelScrollLines(ev); if (this._shouldForwardWheelToApp(ev)) { + // SGR coordinates address the LIVE screen (the bottom `rows` of the + // buffer), so a report computed from a scrolled-up viewport would + // hit-test a different row entirely — and forwarding while the user + // stares at stale scrollback looks like the wheel is dead. Snap back + // first: the wheel then always acts on what the CLI is drawing now. + if (!this._terminalViewportAtBottom()) this.terminal.scrollToBottom(); this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines); return; } @@ -2868,7 +2874,23 @@ Object.assign(CodemanApp.prototype, { } else if (sessionMode !== 'codex') { return false; } - return this._terminalViewportAtBottom(); + // Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so + // that leaving the bottom handed the wheel back to local scrollback and both + // histories stayed reachable without a mode switch. In practice that inverted + // the behavior users actually want: a repaint-mode CLI keeps NO terminal + // scrollback of its own (tmux reports history_size=0 for a Claude pane), so + // xterm's buffer holds only Codeman's REPLAYED repaint frames. Scrolling that + // locally drags the CLI's own pinned furniture (the prompt box, the status + // line) up the screen and shows stale frames underneath, which reads as "the + // window scrolled away" rather than "I am reading history". + // + // And it was easy to fall into: scrollToLastNonEmptyLine() parks the viewport + // `rows - 2` above the last non-empty row, so any tab switch onto a session + // with trailing blank rows left the viewport off-bottom and every later wheel + // went local. Forwarding unconditionally keeps the CLI's transcript as the + // plain wheel's target and its input box fixed in place; local scrollback is + // still on Shift+wheel and on the "Wheel scrolls local history" opt-out above. + return true; }, // Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 7c4ad557..046cbcce 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -311,7 +311,7 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']); }); - it('wheel: forwards to the app only for verified sessions at the buffer bottom without Shift', () => { + it('wheel: forwards to the app for verified sessions without Shift, at ANY scroll position', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]); @@ -323,8 +323,14 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false); // Shift = local scrollback - app.terminal.buffer.active.viewportY = 10; // browsing local scrollback - expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + // Scrolled up into local scrollback still forwards. Gating this on the + // viewport being at the bottom is what let a repaint-mode CLI's own prompt + // box scroll off the screen: scrollToLastNonEmptyLine() parks the viewport + // above the bottom, so a tab switch silently pinned the wheel to local + // scrollback full of stale replayed frames. The wheel handler snaps the + // viewport back to the bottom before encoding the report instead. + app.terminal.buffer.active.viewportY = 10; + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); app.terminal.buffer.active.viewportY = 50; app.terminal.modes.mouseTrackingMode = 'vt200'; // xterm's own encoder live @@ -335,6 +341,24 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); }); + it('wheel: converts deltaMode line/page units instead of assuming pixels', () => { + const { app } = loadTerminalUiHarness(); + app.terminal = { rows: 40 }; + + // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad): ~110px per notch. + expect(app._wheelScrollLines({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4); + // DOM_DELTA_LINE (Firefox mouse wheel): deltaY is already lines. Read as + // pixels this rounded to 0 and fell through to the ±1 fallback. + expect(app._wheelScrollLines({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3); + expect(app._wheelScrollLines({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3); + // DOM_DELTA_PAGE: one page is one screenful. + expect(app._wheelScrollLines({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40); + // A pure horizontal swipe must not fall through to a phantom -1. + expect(app._wheelScrollLines({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0); + // Shift + macOS trackpad reports the magnitude on deltaX (issue #154). + expect(app._wheelScrollLines({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4); + }); + it('wheel: gates claude forwarding on CLI version 2.1.187+ (unknown or older stays local)', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; From dfa43928af5b6ef4722027ea5689dd6d72cd1c38 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 04:33:15 +0200 Subject: [PATCH 3/9] docs: record the scrollback analysis and its measurements for #205 --- docs/scrollback-issues-analysis.md | 255 +++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/scrollback-issues-analysis.md diff --git a/docs/scrollback-issues-analysis.md b/docs/scrollback-issues-analysis.md new file mode 100644 index 00000000..c8d7cd27 --- /dev/null +++ b/docs/scrollback-issues-analysis.md @@ -0,0 +1,255 @@ +# Scrollback issues: analysis and test evidence + +Covers GitHub issue **#205** ("Scrollback in terminal not working", jonocodes, shell mode, +Android + macOS desktop) and the follow-up comment on it from **mtiller** (Firefox on macOS, +"scrolling backward to see agent output"). Related closed issue: **#154** (fixed in 1.3.3). + +Status: **analysis only, nothing implemented.** Measured against the live 1.11.2 instance on +2026-08-06 with throwaway `zz-*` shell sessions (all deleted afterwards; the user's `w*` +sessions were never touched). + +--- + +## TL;DR + +Five distinct problems, not one. #205 is fully explained by finding 1; findings 2 and 3 are +independent and hit **every** mode including Claude, and are the likely substance of the +"similar issue" follow-up. + +| # | Problem | Modes affected | Severity | Confirmed | +| - | ------- | -------------- | -------- | --------- | +| 1 | xterm parked in the **alternate buffer** for the whole session, so there is no scrollback at all and the wheel is translated into Up/Down arrow keys | `shell`, `opencode`, `antigravity` | High | Reproduced end to end | +| 2 | **Bursty output silently destroys a screenful** of the browser's scrollback and adds ~1 row | all | High | Measured | +| 3 | **Tab switch collapses scrollback** to roughly one screen (`full=1` fires once per page load) | all | Medium | Measured | +| 4 | `deltaMode` is never read, so Firefox scrolls ~4x slower per notch | all, Firefox | Low | Static, needs reporter data | +| 5 | **Remote SSH Claude cases get no `claude --version` probe**, so wheel forwarding silently stays off (residual #154) | `claude` + remote | Medium | Static | + +--- + +## Finding 1: shell / opencode / antigravity are stuck in xterm's alternate buffer + +### Root cause + +The local tmux **client** (the `tmux attach` that node-pty spawns) emits `smcup` as its very +first bytes on attach. Captured from a real PTY: + +``` +b'\x1b[?1049h\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2J\x1b[?12l\x1b[?25h\x1b[?1000l...' + ^^^^^^^^^^ enter alternate screen ^^^^^ application cursor keys ON +``` + +`Session._handleTerminalOutput()` strips `\x1b[?1049h` from the live stream, but only when +`isAltScreenStripMode(mode)` is true, and that is `claude | codex | gemini` only +(`src/session.ts:179`). For `shell`, `opencode` and `antigravity` the sequence reaches the +browser verbatim and xterm switches to the alternate buffer, where: + +1. `buffer.active.type === 'alternate'` and `baseY` is pinned at 0, so there is **no + scrollback to reach**. `terminal.scrollLines()` is a no-op, which is why touch scrolling + on Android "does nothing". +2. xterm's own wheel listener takes over. From the vendored bundle + (`src/web/public/vendor/xterm.min.js`): + + ```js + if (!this.buffer.hasScrollback) { + if (ev.deltaY === 0) return false; + if (coreMouseService.consumeWheelEvent(...) === 0) return this.cancel(ev, true); + const seq = ESC + (decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B'); + coreService.triggerDataEvent(seq, true); + return this.cancel(ev, true); + } + ``` + + tmux also set `\x1b[?1h`, so the emitted sequence is `\x1bOA`, i.e. **Up arrow**, straight + into the shell's readline. That is exactly the reported "the mouse wheel scrolls back + through previous commands, like pressing up". + +3. `cancel(ev, true)` calls `preventDefault()` **and `stopPropagation()`**, and xterm's + listener sits on `terminal.element` (a child of Codeman's container). So Codeman's own + container wheel handler, `_shouldForwardWheelToApp` and `_wheelScrollLines` included, is + **never reached** for these modes. That whole path is dead code for shell. + +### Reproduction (live instance, real browser) + +Create a shell session with the page already open, print 150 lines, then dispatch 8 wheel-up +events over `.xterm-screen`: + +``` +t+1500 after shell start {"type":"alternate","length":35,"baseY":0} +t+3000 after shell start {"type":"alternate","length":35,"baseY":0} +after 150 live lines {"type":"alternate","length":35,"baseY":0} +WHEEL on live shell: {"ptyBytes":["OA","OA","OA","OA", + "OA","OA","OA","OA"], + "before":0,"after":0,"type":"alternate"} +``` + +Both reported symptoms, one root cause. + +### Why it looks intermittent + +The alternate-screen sequence only ever reaches the browser through the **live stream at +attach**. Neither replay path carries it: + +- `?full=1` returns `capture-pane` output (`source: mux-full-history`), verified 0 hits for + `\x1b[?1049h`. +- `?tail=` returns the visible pane frame (`source: mux-visible`), also 0 hits; the shell byte + buffer was empty in every probe. +- `_resetTerminalForReplay()` calls `terminal.reset()`, which returns xterm to the normal + buffer. + +So: watching a shell from creation leaves you in the alternate buffer until you reload or +switch tabs, at which point it silently starts working again. Then the next PTY attach (a +restart, or the auto-reattach in `selectSession()`) puts you back. + +### Is stripping safe for shell? Probably yes when tmux-backed, and the current code comment is wrong about why + +`src/session.ts:1404` says *"shell must keep the alt screen for vim/less/htop"*. For a +**tmux-backed** shell that reasoning does not hold: tmux is a full terminal emulator and never +forwards a pane's alternate-screen toggles to its client, it repaints instead. Measured per +phase on a real attach: + +| phase | bytes | `?1049h` | `?1049l` | `?47/1047` | +| ----- | ----: | -------: | -------: | ---------: | +| attach | 772 | **1** | 0 | 0 | +| `seq 1 60` echo | 1402 | 0 | 0 | 0 | +| `less` open / end / quit | 284 / 230 / 321 | 0 | 0 | 0 | +| `vim` open / quit | 2200 / 646 | 0 | 0 | 0 | + +`vim` and `less` inside tmux emit **zero** alternate-screen sequences to the client. + +The caveat that does matter: `startShell()` falls back to a **direct PTY with no tmux** when +mux creation fails (`src/session.ts:1961`, `this._useMux = false`). In that path the inner +app's own `?1049h` does reach xterm, and a blanket strip would break vim/less/htop for real. +Any fix has to be conditional on `_useMux`, which is known server-side. + +Second caveat: stripping alone buys less than it looks like, because of finding 2. It fixes +the wheel (no more phantom Up arrows) and it makes the `full=1` replay reachable, but live +output still will not accumulate. + +--- + +## Finding 2: bursty output silently overwrites a screenful of browser scrollback + +Independent of the alternate buffer, and it hits Claude sessions too. + +tmux decides per flush whether to emit real linefeeds (which push rows into the outer +terminal's scrollback) or to repaint the pane rectangle with cursor addressing (which +overwrites the visible rows in place). When output outpaces its flush interval it coalesces +into a repaint, and one screenful of the browser's history is **destroyed**. + +Measured on one session, same page, `rows = 36`: + +| step | `baseY` | rows containing SEED | BURST | SLOW | +| ---- | ------: | -------------------: | ----: | ---: | +| after `?full=1` replay (120 seeded lines) | 86 | 120 | 0 | 0 | +| after 60 lines emitted as fast as possible | **87** (+1) | **86** (-34) | 35 | 0 | +| after 60 lines at ~16/s (`sleep 0.06`) | **148** (+61) | 86 | 35 | 60 | + +The burst added **one** row of scrollback and ate **34** rows of existing history. The slow +run behaved correctly. So "I printed a bunch of lines and now I cannot scroll back" reproduces +without the alternate buffer being involved at all, and it is rate dependent, which is exactly +the kind of thing that reads as random flakiness. + +Consequence: the browser's scrollback is effectively frozen at whatever the last `?full=1` +replay produced, minus a screen per burst. tmux's own history is fine throughout +(`history_size` kept growing, `history-limit` 2000), so the data is never actually lost +server-side, it just never reaches the browser again until a reload. + +--- + +## Finding 3: switching tabs collapses a session's scrollback + +`_initialFullBufferLoad` is true for the **first buffer load after a page load only** +(`app.js:4374`). Everything after that uses `?tail=`, which returns byte history plus the +visible pane frame. Worse, the snapshot restore path deliberately throws away the restored +xterm snapshot (which does carry scrollback) and replaces it with that frame +(`app.js:4316-4328` plus `needsRewrite`). + +Measured, switching away from session A and back: + +``` +A: initial full=1 load {"len":152,"baseY":116,"AAA":150} +A: after switch away and back {"len": 87,"baseY": 51,"AAA": 59} +``` + +150 lines of history down to 59. Note also that the page's single `full=1` is consumed by +whichever session auto-selects at load, so **every other tab starts life with one frame of +history**. + +--- + +## Finding 4: `deltaMode` is never read (Firefox) + +`grep -rn "deltaMode" src/web/public packages` returns nothing. `_wheelScrollLines()` +(`terminal-ui.js:2818`) treats `deltaY` as pixels unconditionally: + +```js +return Math.round(delta / 25) || (delta > 0 ? 1 : -1); +``` + +Chrome/WebKit report `deltaMode: 0` with `deltaY` around 100 to 120 px per notch, so about 4 +to 5 lines. Firefox reports `deltaMode: 1` (`DOM_DELTA_LINE`) with `deltaY` around 3, so +`Math.round(3/25) === 0` and the `|| ±1` fallback yields **1 line per notch**, roughly 4x +slower. In Claude mode the same value caps the forwarded SGR report at 1 tick per event +instead of 4, so the transcript crawls too. + +This is sluggishness, not breakage, so it is a plausible but unproven contributor to the +mtiller report. No Firefox build is installed under `~/.cache/ms-playwright` (chromium and +webkit only), so this was not measured. Worth asking the reporter for `deltaMode` / `deltaY` +from a live wheel event before acting on it. + +--- + +## Finding 5: remote SSH Claude cases still have no version probe + +`src/session.ts:1490` deliberately skips the deterministic `claude --version` probe for +remote sessions and defers to the startup-banner scrape, which the same comment block +describes as unreliable ("newer Claude Code builds don't print the banner and resumed sessions +never show it"). That is precisely the condition #154 was filed for: `cliVersion` empty means +`_shouldForwardWheelToApp()` returns false, wheel forwarding is off, and the user is left with +local scrollback that (per finding 2) does not accumulate. + +Local and Docker Claude sessions are fine; verified all 7 live sessions report +`cliVersion=2.1.223`, so the 1.3.3 fix is still working there. + +--- + +## Candidate directions (not decided) + +Roughly in order of value per unit of risk. + +1. **Extend the alternate-screen strip to tmux-backed `shell` / `opencode` / `antigravity`.** + Gate on `_useMux` so the direct-PTY fallback keeps vim/less/htop working. Kills the phantom + Up arrows and makes replayed history reachable. `isAltScreenStripMode()` currently takes + only `mode`, so it would need the mux flag threaded in, and + `test/claude-scrollback-strip.test.ts:16-17` plus `test/antigravity-mode.test.ts:116` pin + the current answers and would need updating. + +2. **Re-pull `?full=1` when the user scrolls to the top of the buffer.** Directly addresses + findings 2 and 3 with machinery that already exists and is already proven to return + complete history (200/200 lines in the probe). Needs a guard against refetch storms. + +3. **Stop discarding the xterm snapshot on tab switch**, or request `full=1` on the first load + per session rather than per page. Cheaper partial fix for finding 3 alone. + +4. **Read `ev.deltaMode`** in `_wheelScrollLines()` and normalise line/page deltas to lines. + Small, self-contained, worth doing regardless of whether it is mtiller's actual bug. + +5. **Probe the CLI version over SSH for remote Claude cases**, mirroring the deferred + in-container probe that Docker cases already use. + +Option 1 alone does not fix #205's "print a bunch of lines then scroll" complaint; that needs +2 as well. + +## Reproduction assets + +Scripts used, in the session scratchpad +(`/tmp/claude-1000/-home-arkon-default-claudeman/597ffc9f-.../scratchpad/`): + +- `ptycap.py` / `ptycap2.py`: PTY-level capture of the tmux client stream, per phase counts of + alternate-screen and mouse-tracking sequences. +- `sim.mjs`: replays a captured stream through `@xterm/headless` with and without the strip. +- `browser-test*.mjs`: Playwright against the live instance, reports `buffer.active.type`, + `baseY`, row content and the exact bytes xterm sends to the PTY on a wheel event. + +`@xterm/headless` was installed with `npm i --no-save`, so `package.json` and the lockfile are +untouched. From a1d7ec02e9de44c088d846307bfe2a497d6e97f7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 05:05:25 +0200 Subject: [PATCH 4/9] fix(terminal): forward touch scrolls to the CLI transcript on mobile Touch drags and flick momentum on forwarding-capable sessions (codex, claude >= 2.1.187) now go to the CLI as coalesced SGR wheel reports via the shared _forwardScrollToApp helper, exactly like the desktop wheel: snap the viewport home first, then encode. Before this, every phone or tablet swipe scrolled the local buffer of stale repaint frames and dragged the CLI's pinned input box off the screen (the mobile half of issue #205). The _shouldForwardWheelToApp gate is shared, so the local-scrollback opt-out setting and the CLI version gate apply to touch exactly as they do to the wheel; shell and other local modes keep the existing local touch scrolling and the scroll-to-top history re-pull. Co-Authored-By: Claude Fable 5 --- src/web/public/terminal-ui.js | 54 +++++++++++++++++++++++++-------- test/terminal-touch-tap.test.ts | 33 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 449178c5..d6b9fd33 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -424,13 +424,7 @@ Object.assign(CodemanApp.prototype, { ev.preventDefault(); const lines = this._wheelScrollLines(ev); if (this._shouldForwardWheelToApp(ev)) { - // SGR coordinates address the LIVE screen (the bottom `rows` of the - // buffer), so a report computed from a scrolled-up viewport would - // hit-test a different row entirely — and forwarding while the user - // stares at stale scrollback looks like the wheel is dead. Snap back - // first: the wheel then always acts on what the CLI is drawing now. - if (!this._terminalViewportAtBottom()) this.terminal.scrollToBottom(); - this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines); + this._forwardScrollToApp(ev.clientX, ev.clientY, lines); return; } this._noteTerminalUserScroll(lines); @@ -444,9 +438,17 @@ Object.assign(CodemanApp.prototype, { // xterm.js DOM renderer doesn't populate xterm-viewport's scroll area, // so native CSS scrolling (overflow-y: scroll + touch-action: pan-y) // has nothing to scroll. Instead, convert touch deltas into scrollLines() - // calls, matching the wheel handler above. + // calls, matching the wheel handler above, including the forwarding + // branch: for the sessions whose wheel goes to the CLI's own transcript + // (_shouldForwardWheelToApp), a touch drag must go there too, or every + // phone/tablet swipe scrolls the local buffer of stale repaint frames and + // drags the CLI's pinned input box off the screen (issue #205's mobile + // half). Same gate, so Shift has no touch analog but the local-scrollback + // opt-out setting and the CLI-version gate apply to touch exactly as they + // do to the wheel. { const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13; + let touchLastX = 0; let touchLastY = 0; let velocity = 0; let lastTime = 0; @@ -461,8 +463,14 @@ Object.assign(CodemanApp.prototype, { // Momentum phase — convert pixel velocity to lines const lines = Math.round(velocity / cellHeight()); if (lines !== 0) { - this.terminal.scrollLines(lines); - this._maybeLoadMoreHistoryOnScroll(lines); + if (this._shouldForwardWheelToApp({ shiftKey: false })) { + // Flick momentum keeps feeding the CLI's transcript from the last + // touch point; the 40ms coalescer batches the per-frame reports. + this._forwardScrollToApp(touchLastX, touchLastY, lines); + } else { + this.terminal.scrollLines(lines); + this._maybeLoadMoreHistoryOnScroll(lines); + } } velocity *= 0.92; scrollFrame = requestAnimationFrame(scrollLoop); @@ -484,6 +492,7 @@ Object.assign(CodemanApp.prototype, { 'touchstart', (ev) => { if (ev.touches.length === 1) { + touchLastX = ev.touches[0].clientX; touchLastY = ev.touches[0].clientY; touchStartY = touchLastY; velocity = 0; @@ -519,14 +528,19 @@ Object.assign(CodemanApp.prototype, { const delta = touchLastY - touchY; // positive = scroll down pixelAccum += delta; velocity = delta * 1.2; + touchLastX = ev.touches[0].clientX; touchLastY = touchY; // Convert accumulated pixels to whole lines const ch = cellHeight(); const lines = Math.trunc(pixelAccum / ch); if (lines !== 0) { - this._noteTerminalUserScroll(lines); - this.terminal.scrollLines(lines); - this._maybeLoadMoreHistoryOnScroll(lines); + if (this._shouldForwardWheelToApp({ shiftKey: false })) { + this._forwardScrollToApp(touchLastX, touchLastY, lines); + } else { + this._noteTerminalUserScroll(lines); + this.terminal.scrollLines(lines); + this._maybeLoadMoreHistoryOnScroll(lines); + } pixelAccum -= lines * ch; } } @@ -2034,6 +2048,20 @@ Object.assign(CodemanApp.prototype, { if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.(); }, + /** + * Hand a scroll gesture (wheel tick or touch drag, already converted to + * lines) to the CLI as synthetic SGR wheel reports. SGR coordinates address + * the LIVE screen (the bottom `rows` of the buffer), so a report computed + * from a scrolled-up viewport would hit-test a different row entirely, and + * forwarding while the user stares at stale scrollback looks like the + * gesture is dead. Snap back first: the gesture then always acts on what the + * CLI is drawing now. + */ + _forwardScrollToApp(clientX, clientY, lines) { + if (!this._terminalViewportAtBottom()) this.terminal.scrollToBottom(); + this._sendSyntheticSgrWheel(clientX, clientY, lines); + }, + _hasRecentUserScrollUp() { if (typeof this._lastUserScrollUpAt !== 'number') return false; return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS; diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 046cbcce..773ddc48 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -462,6 +462,39 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toHaveLength(1); }); + it('forwarded scrolls (wheel AND touch) snap the viewport home first, then encode SGR ticks', () => { + const { app } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data }); + const scrolledToBottom: boolean[] = []; + app.terminal = { + cols: 80, + rows: 24, + // Scrolled up into local scrollback: SGR coordinates address the LIVE + // screen, so the report would hit-test the wrong row without the snap. + buffer: { active: { viewportY: 10, baseY: 50 } }, + scrollToBottom: () => scrolledToBottom.push(true), + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + app._forwardScrollToApp(50, 50, -3); + expect(scrolledToBottom).toEqual([true]); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(3) }]); + + // Already at the bottom: no snap, just the report. + app.terminal.buffer.active.viewportY = 50; + app._forwardScrollToApp(50, 50, 2); + expect(scrolledToBottom).toHaveLength(1); + app._flushWheelSgrQueue(); + expect(sent).toHaveLength(2); + }); + it('allows trusted mouse events after the tap window expires', () => { const { app, setNow } = loadTerminalUiHarness(); const { element, dispatch } = createElementHarness(); From a7a1cef3d63a5fb19805ec49fbbdcd87c40b20d1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 05:05:41 +0200 Subject: [PATCH 5/9] fix(session): probe the Claude CLI version over ssh for remote sessions Remote Claude sessions were the one backend left relying on the startup-banner scrape for cliVersion (the unreliable path #154 was filed for: newer Claude Code builds print no banner and resumed sessions never do), so wheel/touch forwarding silently stayed off for them. Mirror the docker approach: a deferred best-effort probe at session start, running claude --version on the remote host through the same buildSshConnectionArgs + login-shell wrapper as the real launch, parsing the first semver in stdout (an interactive login shell may echo rc-file noise around it). Co-Authored-By: Claude Fable 5 --- src/remote-hosts.ts | 63 +++++++++++++++++++++++++++++++++ src/session.ts | 33 +++++++++++++++-- test/remote-ssh-options.test.ts | 33 ++++++++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 753d45ee..9d43213c 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -256,6 +256,69 @@ export async function checkRemoteTmuxAvailable( } } +/** + * The CLI binary each session mode runs on the remote host. Antigravity's + * binary is `agy` (the mode name is not the command); shell has no CLI to + * probe, so it is absent. + */ +const REMOTE_CLI_BIN: Partial> = { + claude: 'claude', + opencode: 'opencode', + codex: 'codex', + gemini: 'gemini', + antigravity: 'agy', +}; + +/** + * Build the SSH command that reads the remote CLI's version (`claude --version` + * on the remote host). The version query is routed through + * `remoteLoginShellCommand` (the SAME `$SHELL -i -l -c` wrapper the real + * launch uses), because agent CLIs live on PATH only after the remote user's + * interactive-login startup files run (see defaultRemoteCommandForMode); a bare + * `claude --version` over ssh exits 127. Connection options come from the + * shared `buildSshConnectionArgs`, so the probe reaches exactly the hosts the + * launch can reach. Returns null for modes with no CLI (shell). + */ +export function buildRemoteCliVersionProbeCommand( + host: Pick & RemoteSshOptions, + mode: SessionMode +): string | null { + const bin = REMOTE_CLI_BIN[mode]; + if (!bin) return null; + return [ + ...buildSshConnectionArgs(host), + remoteSshTarget(host), + shellescape(remoteLoginShellCommand(`${bin} --version`)), + ].join(' '); +} + +/** + * Read the CLI version installed ON THE REMOTE HOST. Feeds Session.cliVersion + * for remote sessions: the deterministic local probe deliberately skips them + * (it would report the LOCAL host's claude), and the startup-banner scrape is + * unreliable (newer Claude Code builds print no banner; resumed sessions never + * do), which left cliVersion undefined and silently disabled wheel-forwarding + * to the CLI transcript (residual #154, noted in the #205 analysis). The + * version is parsed as the first semver in stdout, never raw output: an + * interactive-login shell may echo rc-file noise around it. Returns undefined + * on any failure. No-op under VITEST (mirrors checkRemoteTmuxAvailable). + */ +export async function probeRemoteCliVersion( + host: Pick & RemoteSshOptions, + mode: SessionMode +): Promise { + if (process.env.VITEST) return undefined; + const command = buildRemoteCliVersionProbeCommand(host, mode); + if (!command) return undefined; + try { + const { stdout } = await execAsync(command, { timeout: 15_000 }); + const match = stdout.match(/\d+\.\d+\.\d+/); + return match ? match[0] : undefined; + } catch { + return undefined; + } +} + /** * COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a * remote host's canonical `-L codeman` socket. diff --git a/src/session.ts b/src/session.ts index 989a5e1f..23e29271 100644 --- a/src/session.ts +++ b/src/session.ts @@ -54,6 +54,7 @@ import { type SessionDocker, } from './types.js'; import { probeDockerCliVersion } from './docker-hosts.js'; +import { probeRemoteCliVersion } from './remote-hosts.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; @@ -226,6 +227,8 @@ const IS_TEST_MODE = !!process.env.VITEST; const TEST_PTY_SCRIPT = 'if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.pipe(process.stdout);'; /** Delay before the in-container Claude CLI version probe (lets the container start). */ const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000; +/** Delay before the over-ssh Claude CLI version probe (keeps session start off the ssh round-trip). */ +const REMOTE_CLI_VERSION_PROBE_DELAY_MS = 3000; /** * Ask tmux for the current window geometry of `muxName` so a re-attaching PTY @@ -1534,8 +1537,8 @@ export class Session extends EventEmitter { // never show it — which left cliVersion undefined and silently disabled // wheel-forwarding to Claude's own transcript (the only route to history in // repaint/alt-screen mode; issue #154). Remote sessions run claude on - // another host, so a local probe wouldn't reflect their version — skip them - // and let the banner scrape handle those. Cached process-wide, best-effort. + // another host, so a local probe wouldn't reflect their version; they get + // their own over-ssh probe below. Cached process-wide, best-effort. if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) { const probedVersion = getClaudeCliVersion(); if (probedVersion) { @@ -1574,6 +1577,32 @@ export class Session extends EventEmitter { }, DOCKER_CLI_VERSION_PROBE_DELAY_MS); } + // Remote sessions run claude on ANOTHER HOST, so neither the local nor the + // docker probe applies, and the banner-scrape fallback they were left with + // is the unreliable path #154 was filed for, so remote Claude cases silently + // never got wheel-forwarding (noted in the #205 analysis). Probe over ssh, + // deferred so session start never waits on the ssh round-trip. + if (this.mode === 'claude' && this._remote && !this._cliVersion) { + const remoteMeta = this._remote; + setTimeout(() => { + if (this._isStopped || this._cliVersion) return; + void probeRemoteCliVersion(remoteMeta, this.mode) + .then((version) => { + if (!version || this._isStopped || this._cliVersion) return; + this._cliVersion = version; + this.emit('cliInfoUpdated', { + version: this._cliVersion, + model: this._cliModel, + accountType: this._cliAccountType, + latestVersion: this._cliLatestVersion, + }); + }) + .catch(() => { + /* best-effort */ + }); + }, REMOTE_CLI_VERSION_PROBE_DELAY_MS); + } + // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { diff --git a/test/remote-ssh-options.test.ts b/test/remote-ssh-options.test.ts index f13fc8b4..cb565424 100644 --- a/test/remote-ssh-options.test.ts +++ b/test/remote-ssh-options.test.ts @@ -20,7 +20,12 @@ import { homedir } from 'node:os'; import { describe, it, expect } from 'vitest'; -import { buildSshConnectionArgs, buildRemoteTmuxCheckCommand, remoteSshTarget } from '../src/remote-hosts.js'; +import { + buildSshConnectionArgs, + buildRemoteTmuxCheckCommand, + buildRemoteCliVersionProbeCommand, + remoteSshTarget, +} from '../src/remote-hosts.js'; import { buildRemoteLaunchCommand } from '../src/tmux-manager.js'; import type { SessionRemote } from '../src/types.js'; @@ -198,3 +203,29 @@ describe('COD-107 buildRemoteTmuxCheckCommand — same connection options as the expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42', port: 2222 })).toContain('-p 2222'); }); }); + +describe('buildRemoteCliVersionProbeCommand: remote CLI version over the same connection (#205)', () => { + it('routes the version query through the interactive-login shell wrapper, like the launch', () => { + const cmd = buildRemoteCliVersionProbeCommand(baseRemote, 'claude'); + // Same PATH-resolution wrapper as defaultRemoteCommandForMode: a bare + // `claude --version` over ssh sees only sshd's minimal PATH (exit 127). + expect(cmd).toBe( + 'ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 ' + + `'exec "\${SHELL:-/bin/sh}" -i -l -c '\\''claude --version'\\'''` + ); + }); + + it('uses the shared connection args (proxy/identity/port), so it reaches what the launch reaches', () => { + const cmd = buildRemoteCliVersionProbeCommand(aaDesktop, 'claude'); + expect(cmd).toContain('-o BatchMode=yes'); + expect(cmd).toContain('-p 2222'); + expect(cmd).toContain(`-i '${HOME}/.ssh/remote_ed25519'`); + expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'"); + expect(cmd).toContain('aakht@192.168.55.170'); + }); + + it('maps antigravity to its real binary name and shell to no probe at all', () => { + expect(buildRemoteCliVersionProbeCommand(baseRemote, 'antigravity')).toContain('agy --version'); + expect(buildRemoteCliVersionProbeCommand(baseRemote, 'shell')).toBeNull(); + }); +}); From ad2ca9b5752ebc21ccc55417c66a0de92ca5b634 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 05:05:42 +0200 Subject: [PATCH 6/9] docs: record the #205 scrollback mechanisms and the shipped fix plan Update the full-scrollback replay invariant (per-session full=1 Set plus the scroll-to-top re-pull), add a new invariants section covering the two strip flavors and the wheel/touch forwarding rules, sync the CLAUDE.md Key Patterns bullets, and commit the fix plan with a status header describing what shipped and where it deliberately diverged (narrow strip plus re-pull instead of tmux mouse on; viewport-at-bottom gate dropped). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- docs/architecture-invariants.md | 8 ++- docs/scrollback-fix-plan.md | 112 ++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 docs/scrollback-fix-plan.md diff --git a/CLAUDE.md b/CLAUDE.md index cb81f7fa..1237e70e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,7 +206,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit) -**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. Only the FIRST buffer load after a page load requests `full=1`; tab switches keep the cheap `?tail=` path. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) +**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) + +**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for codex/claude ≥ 2.1.187 at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Self-update** (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ae1c143e..5c654743 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -58,7 +58,13 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ### Full-scrollback replay -**Full-scrollback replay** (COD-164/#148): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). Only the FIRST buffer load after a page load requests `full=1` (one-shot `_initialFullBufferLoad` flag in app.js); tab switches keep the cheap `?tail=` visible-frame path. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`. +**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load OF EACH SESSION per page load requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other tab one frame of history); later switches keep the cheap `?tail=` visible-frame path. On top of that, scrolling up while already at the TOP of the buffer re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay). The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`. + +### Terminal scrollback: strip flavors and wheel/touch forwarding + +**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend `_sessionUsesServerMouseStrip()` mirror stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`. + +**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (codex, claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. ### Run launch synchronization diff --git a/docs/scrollback-fix-plan.md b/docs/scrollback-fix-plan.md new file mode 100644 index 00000000..acc21f3d --- /dev/null +++ b/docs/scrollback-fix-plan.md @@ -0,0 +1,112 @@ +# Scrollback fix plan (issue #205) + +Status: IMPLEMENTED on `fix/scrollback-shell-alt-screen` (2026-08-07), with one deliberate +divergence from the recommendation below. Kept for the diagnosis record; the measured evidence +behind it is `docs/scrollback-issues-analysis.md`, and the mechanisms as shipped are documented +in `docs/architecture-invariants.md` (§ Full-scrollback replay, § Terminal scrollback: strip +flavors and wheel/touch forwarding). + +What shipped vs. what this doc proposed: + +- **Bug A (deltaMode)**: implemented as specified (`_wheelScrollLines()` normalizes + line/page/pixel units, Shift-axis trap kept). +- **Bug B (shell scrollback)**: implemented via the NARROW alt-screen strip for tmux-backed + shell/opencode/antigravity plus the scroll-to-top `full=1` re-pull, NOT the recommended + approach (a) `tmux mouse on`. The measurements in the analysis doc showed the alt buffer + comes from tmux's own client-side `smcup` at attach (tmux never forwards a pane program's + alt-screen toggles), so stripping that one sequence fixes both symptoms with no selection + tradeoff, keeps vim/less/htop untouched, and the re-pull also covers the repaint-burst + history loss that `mouse on` would not have addressed. +- **Invariant change**: the "viewport-at-bottom gate stays" invariant below was deliberately + DROPPED for forwarding modes: a repaint-mode CLI keeps no real terminal scrollback, so the + gate pinned users to a buffer of stale frames whenever the viewport parked off-bottom. + Forwarding now snaps to bottom first; Shift+wheel and the opt-out setting keep local + scrollback reachable. Touch forwards through the same gate (the mobile half of the fix). +- **Finding 5 (remote probe)**: implemented (`probeRemoteCliVersion` over ssh, deferred at + session start, same login-shell wrapper as the launch). + +Original plan follows. + +## Reports + +- **Issue #205** (https://github.com/Ark0N/Codeman/issues/205), OPEN: + - **jonocodes** (author, 2026-08-03): SHELL session. Host Mac M4, brew tmux. On Android, touch-scrolling the terminal does nothing. On desktop, the mouse wheel cycles shell command history (acts like Up/Down arrows) instead of scrolling the screen. + - **mtiller** (comment, 2026-08-06): "similar issue just with scrolling backward to see agent output. This is with Firefox on MacOS." (Claude session implied.) +- **Reddit r/selfhosted** comment `p21x6ts` by mmtiller (= mtiller on GitHub): scrolling broken enough across phone/iPad/laptop that they fall back to Claude's own remote-control feature. Churn-risk user who otherwise loves the product; fixing this has promo value beyond the bug itself. + +## How scrolling works today (read this before touching anything) + +Three independent paths, all in `src/web/public/terminal-ui.js` unless noted: + +1. **Desktop wheel** (container `wheel` listener, ~line 421): ALWAYS `preventDefault()`s, then either + - forwards synthetic SGR wheel reports to the app (`_sendSyntheticSgrWheel`, coalesced every 40ms, fire-and-forget) when `_shouldForwardWheelToApp(ev)` (~line 2823) passes: no Shift held, opt-out setting `terminalWheelLocalScrollback` off, xterm `mouseTrackingMode === 'none'`, session mode is `claude` with `cliVersion >= 2.1.187` or `codex`, and viewport is at bottom; + - otherwise scrolls xterm's LOCAL scrollback via `terminal.scrollLines(lines)`. + - `lines` comes from `_wheelScrollLines(ev)` (~line 2818): `delta / 25`, i.e. it assumes PIXEL deltas. + - NOTE: xterm.js's own internal wheel handler sits on an element INSIDE the container, so it runs FIRST (bubble order) and is not suppressed by the container's `preventDefault`. +2. **Touch** (touchstart/move/end, ~lines 441-585): converts touch deltas to `terminal.scrollLines()` with momentum. Touch is ALWAYS local-scrollback, never forwarded to the app. Tap-to-position (touchend, ~line 533) is separate and already handles both mouse-tracking-on and server-strip cases. +3. **Server-side strip** (`_handleTerminalOutput`, `src/session.ts:1384`): for modes in `isAltScreenStripMode()` (`src/session.ts:179` = `codex | claude | gemini`), strips alt-screen switches (`?47/?1047/?1049`), scrollback erase (`3J`), and mouse-tracking DECSETs (`?1000-?1007` except `?1004` focus) so content stays in xterm's normal buffer with scrollback intact. Includes a chunk-boundary carry so split sequences can't leak. `shell` and `opencode` (and `antigravity`) are deliberately EXCLUDED: arbitrary shell programs (vim/less/htop) legitimately need the alt screen. There is a parity copy of this strip on the replay path (`src/web/routes/session-routes.ts`, ~line 1697) and a frontend parity check `_sessionUsesServerMouseStrip()` (terminal-ui.js ~line 2751). All three must stay in sync. +4. Related: full-scrollback replay (`GET .../terminal?full=1` on first buffer load) fills xterm local scrollback; client scrollback is hardcoded 50k (`DEFAULT_SCROLLBACK`, constants.js) vs tmux 100k. + +## Diagnosis + +### Bug A: Firefox wheel deltas (mtiller's desktop case) + +`_wheelScrollLines()` divides by 25 assuming `WheelEvent.deltaY` is pixels (`deltaMode === 0`, Chrome/Safari behavior). Firefox commonly fires `deltaMode === 1` (LINE units, deltaY around 1-3 per notch), so `Math.round(3/25) = 0` and the `|| ±1` fallback yields 1 line per event. With a discrete mouse wheel that is 1 line per notch: scrolling feels dead/broken. This hits BOTH the local-scroll path and the forwarded path, since both use the same function. + +**Fix**: normalize by `ev.deltaMode` in `_wheelScrollLines()`: +- `deltaMode 0` (pixels): current behavior, `delta / 25`. +- `deltaMode 1` (lines): use the delta directly (round, keep sign fallback). +- `deltaMode 2` (pages): `delta * terminal.rows` (or a sane page size). +Keep the existing Shift-axis trap intact: on macOS trackpads Shift+two-finger scroll arrives as a HORIZONTAL wheel (deltaX carries the magnitude, deltaY ~0); that's why the function reads deltaX when Shift is held (issue #154). Don't lose it. + +**Verify**: don't trust this diagnosis blindly. First reproduce in real Firefox on macOS and log `deltaMode`/`deltaY` (Firefox trackpad input can arrive as pixels; external mouse as lines). Also confirm the session's `cliVersion` probe succeeded (a failed probe disables forwarding entirely, which would point elsewhere). Unit-test by dispatching synthetic `WheelEvent`s with explicit `deltaMode` values; a Playwright `firefox` project pass is the end-to-end check. + +### Bug B: shell mode has NO working scrollback at all (jonocodes) + +Chain: shell mode is excluded from the alt-screen strip (correctly) → tmux attaches on the alternate screen → xterm's alt buffer has zero scrollback. Consequences: +- **Wheel**: xterm's own internal wheel handler runs first and, in the alt buffer, converts wheel ticks into Up/Down arrow keys (alternateScroll behavior). The shell receives arrows → command history cycles. That is jonocodes' exact desktop symptom. The container handler's `scrollLines()` afterwards is a no-op (no scrollback in alt buffer). +- **Touch**: the touch handler's `scrollLines()` is equally a no-op → "scrolling does nothing" on Android. Exact symptom two. +- The real history exists the whole time in tmux's 100k-line buffer; nothing exposes it. + +**Fix, recommended approach (a): enable tmux `mouse on` for shell sessions.** +- Server-side, set `mouse on` scoped to shell sessions' tmux sessions (`tmux set-option -t mouse on` at create + on attach of recovered sessions). Do NOT set it globally on the socket: claude/codex/gemini sessions rely on the DECSET strip and must not change. +- What this buys, all natively: tmux enables mouse tracking on the outer terminal → xterm `mouseTrackingMode` goes non-none → the container handler stands down (line ~2830 check) and xterm's own encoder forwards wheel as SGR reports → tmux scrolls its OWN copy-mode history on wheel-up, auto-exits at bottom. The alt-scroll arrow conversion disappears too (tracking mode takes precedence). Desktop is fully fixed with no new endpoints. +- **Touch**: still needs one small client change: in the touchmove path, when the active session is `shell` AND `mouseTrackingMode !== 'none'`, convert accumulated lines to `_sendSyntheticSgrWheel(x, y, lines)` instead of `scrollLines()`. The 40ms coalescing already prevents the tmux process storm (each send is a tmux send-keys server-side; unbatched flicks would spawn dozens of processes: this constraint is documented at `_sendSyntheticSgrWheel`, do not bypass it). +- **Selection tradeoff to verify**: with tracking on, xterm hands drag events to tmux instead of doing local browser selection. Shift+drag still does local selection (xterm shift-override). Verify this UX on desktop before shipping; if it's unacceptable, fall back to approach (b). +- **Also verify**: vim/less/htop inside the shell still behave (they'll now receive real mouse events via tmux, generally an improvement); remote shell sessions run tmux on the REMOTE host (`tmux -L codeman-remote`) and need the same option set there if remote shells are in scope (fine to defer, note it in the changeset if skipped). + +**Fallback approach (b), only if (a)'s selection tradeoff fails testing**: keep mouse off; when a shell session is in the alt buffer, have the client send scroll intents to a small server endpoint that drives `tmux copy-mode -e -t ` + `send-keys -X -N scroll-up/down`. Preserves selection semantics exactly, but needs a new endpoint, server-side batching, AND suppression of xterm's native alt-scroll arrow conversion (capture-phase wheel listener with `stopPropagation`, or `attachCustomWheelEventHandler` if the vendored xterm version has it). More moving parts; (a) should be tried first. + +**Not acceptable**: adding `shell` to `isAltScreenStripMode()`. vim/less/htop need the alt screen; that exclusion is deliberate and documented. + +### Bug C: mtiller's phone/iPad case — UNREPRODUCED, do not guess + +Touch is always-local by design, and Claude sessions keep content in the normal buffer (strip), so touch scrollback "should" work there. Before coding anything: build a repro matrix (iPhone Safari / iPad Safari / Android Chrome × claude / shell) on the current release. Plausible candidates if it does reproduce: auto-scroll-to-bottom fighting user scrolls (`_noteTerminalUserScroll`, ~line 2004), or they were in shell sessions on mobile too (then Bug B covers it). Ask mtiller on #205 for session mode + Codeman version if the matrix comes up clean. + +## Invariants the implementation MUST respect + +- Shift+wheel always scrolls local scrollback; the trackpad Shift-axis handling from #154 stays. +- The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local). +- The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom. +- 40ms SGR coalescing: never send per-event writes to the server. +- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three. +- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). +- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened. + +## Testing (per repo rules) + +- `npm test -- test/.test.ts` only; never bare `npm test`. New test ports 3150+, never 3000. +- Browser-test traps (documented in CLAUDE.md Testing): drive input/scroll through real events (`page.mouse.wheel`, real touch), not app internals; headless Chromium reports `isTouchDevice()` false even with `hasTouch: true`; assert on real state (xterm viewport position, `tmux -L codeman capture-pane`), not HTTP 200. +- Shell-mode E2E: create a throwaway shell session, `seq 1 500`, then (1) wheel up on desktop shows earlier lines, not history cycling; (2) touch-scroll on a phone shows earlier lines; (3) `vim` + `less` still enter/leave the alt screen cleanly; (4) Shift+drag still selects text. +- Firefox E2E: Playwright `firefox` project, wheel over a Claude session's finished output, assert viewport moved more than 1 line per notch. +- End-to-end against the REAL environment before claiming done (standing user rule). w1/w2/w3 tmux sessions are the user's live sessions: never send input to them; create your own throwaway session and DELETE it by exact id when done. + +## Related observation (not a reported bug, worth a look while in there) + +The `claude --version` probe that feeds the forwarding gate runs only for local and docker sessions (`src/session.ts:1490` gates `!this._remote`; docker handled at :1507). Remote Claude sessions therefore never get `cliVersion` and silently keep local-only wheel. Harmless (local scrollback works) but inconsistent; cheap to fix by probing over ssh, or document as intended. + +## Rollout + +1. Bug A (deltaMode) is small and independent: can ship alone as a patch. +2. Bug B (shell scrollback) is the headline fix for #205: patch or minor per COM flow. +3. After deploy + verification: comment on #205 (what was fixed, what needs their retest), then reply to the Reddit comment `p21x6ts` with the release version. Both reporters gave environment details; address them specifically. From c067167dbc84fe74def312e676f38bc9515a7e4e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 13:03:17 +0200 Subject: [PATCH 7/9] fix(terminal): take the wheel in capture phase; xterm's scroller is deaf after reset Measured on the live instance: xterm's vscode-style viewport scroller consumes wheel events itself whenever it believes a scrollbar exists (preventDefault + stopPropagation, attachCustomWheelEventHandler is not consulted), so Codeman's bubble-phase handler never fired once local scrollback existed. Forwarding, the deltaMode conversion and the top-of-buffer history re-pull were all silently dead exactly on the sessions that had history, which is the 'input box scrolls up then it fights and hangs' report. Worse, that scroller's dimensions go stale after terminal.reset(): following a tab switch or full-history replay it neither scrolls nor propagates, which is the 'works at first, breaks after reload and tab switch' report. The container wheel listener now runs in capture phase, stops propagation, and scrolls locally through buffer-level scrollLines(), which keeps working after resets. Mouse-tracking sessions and the alternate buffer (direct-PTY vim/less) are passed through untouched so xterm's encoder and alt-scroll arrow conversion keep owning those. Verified end to end against the beta: 9/9 matrix checks including the exact reported flows (claude wheel with scrollback present stays pinned and forwards, shell reaches full history by wheel alone, reload then tab switch then back still works, SSE reconnect survives, Shift+wheel stays local), plus the two prior E2E suites re-passing 10/10 and 6/6. Co-Authored-By: Claude Fable 5 --- docs/architecture-invariants.md | 2 ++ src/web/public/terminal-ui.js | 31 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 5c654743..2bb8c74d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -66,6 +66,8 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (codex, claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. +**The wheel listener is CAPTURE-phase and Codeman owns the scroll** (#205 follow-up, measured on the live instance): xterm's viewport is a vscode-style ScrollableElement that consumes wheel events itself (preventDefault + stopPropagation) whenever it believes a scrollbar exists, ignores `attachCustomWheelEventHandler`, and goes DEAF after `terminal.reset()` — a tab switch or full-history replay leaves its scroll dimensions stale, after which wheel events neither scroll nor propagate reliably. A bubble-phase container listener therefore never fired once local scrollback existed (forwarding, deltaMode and the top-of-buffer re-pull all silently dead exactly on sessions WITH history), and after a tab switch nothing scrolled at all ("works at first, breaks after a tab switch"). The container wheel listener is `{capture: true}`, stops propagation, and scrolls locally via buffer-level `terminal.scrollLines()` (immune to the stale scroller). ⚠️ Two cases are deliberately passed through untouched, in this order BEFORE preventDefault: `mouseTrackingMode !== 'none'` (xterm's encoder forwards the wheel to the PTY — htop/vim with mouse on) and `buffer.active.type === 'alternate'` (direct-PTY vim/less: xterm's alt-scroll converts the wheel to cursor keys). Do not "simplify" this back to a bubble listener or re-delegate local scrolling to xterm's viewport. E2E guard: the reload → tab-switch → wheel matrix in the #205 verification scripts. + ### Run launch synchronization **Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w-` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`. diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d6b9fd33..bb10cf60 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -418,10 +418,39 @@ Object.assign(CodemanApp.prototype, { // history lives there); the plain wheel stays on the CLI's transcript for // those modes regardless of scroll position, so the CLI's input box never // slides off the screen (see _shouldForwardWheelToApp). + // + // CAPTURE phase, deliberately, and Codeman owns the scroll. xterm's + // viewport is a vscode-style ScrollableElement that consumes wheel events + // itself (preventDefault + stopPropagation) whenever it believes a + // scrollbar exists, does NOT consult attachCustomWheelEventHandler, and — + // measured on the live instance — goes DEAF after terminal.reset(): a tab + // switch or full-history replay leaves its scroll dimensions stale, after + // which wheel events neither scroll nor propagate reliably. A bubble-phase + // listener here therefore never fired once local scrollback existed + // (measured: _shouldForwardWheelToApp call count stayed 0 while xterm + // scrolled), and after a tab switch NOTHING scrolled at all — the "input + // box scrolls up then it fights", "works at first, breaks after a tab + // switch" reports on #205. + // + // So: capture runs ancestors-first; this handler sees every wheel first + // and stops propagation, keeping xterm's scroller out of it entirely. + // Local scrolling goes through terminal.scrollLines() — buffer-level, so + // it keeps working after resets — with our own deltaMode normalization + // (_wheelScrollLines) covering Firefox's line-unit wheels. Two cases still + // belong to xterm and are passed through untouched: + // - mouseTrackingMode active: xterm's own encoder forwards the wheel to + // the PTY (htop/vim with mouse on in a shell pane); + // - alternate buffer (direct-PTY fallback running vim/less): xterm's + // alt-scroll handling converts the wheel to cursor keys, which is what + // those apps expect. container.addEventListener( 'wheel', (ev) => { + const trackingMode = this.terminal?.modes?.mouseTrackingMode; + if (trackingMode && trackingMode !== 'none') return; + if (this.terminal?.buffer?.active?.type === 'alternate') return; ev.preventDefault(); + ev.stopPropagation(); const lines = this._wheelScrollLines(ev); if (this._shouldForwardWheelToApp(ev)) { this._forwardScrollToApp(ev.clientX, ev.clientY, lines); @@ -431,7 +460,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.scrollLines(lines); this._maybeLoadMoreHistoryOnScroll(lines); }, - { passive: false } + { passive: false, capture: true } ); // Touch scrolling — use terminal.scrollLines() for all devices. From 5f2b491d9914e9b514dadfecf29a282b3ecdd24e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 13:30:34 +0200 Subject: [PATCH 8/9] feat(terminal): ease-out smooth scrolling for the local wheel path The capture-phase handler owns local scrolling (xterm's smooth scroller is bypassed for the stale-dimensions reasons documented there), which made every notch an instant multi-line jump. Wheel deltas now accumulate into a pending line count drained ~35% per animation frame with a one-line floor, so scrolling glides and extra notches mid-glide read as acceleration. Pending momentum is dropped on session switch so it never scrolls the tab the user just switched to. Verified on the beta: a 20-line notch eases over 9 frames to an exact landing, and the 9-check scroll matrix still passes. Co-Authored-By: Claude Fable 5 --- src/web/public/terminal-ui.js | 37 +++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index bb10cf60..71c673f2 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -457,8 +457,7 @@ Object.assign(CodemanApp.prototype, { return; } this._noteTerminalUserScroll(lines); - this.terminal.scrollLines(lines); - this._maybeLoadMoreHistoryOnScroll(lines); + this._smoothScrollBy(lines); }, { passive: false, capture: true } ); @@ -2077,6 +2076,40 @@ Object.assign(CodemanApp.prototype, { if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.(); }, + /** + * Ease-out smooth scrolling for the local wheel path. The capture-phase + * wheel handler owns local scrolling (xterm's own smooth scroller is + * bypassed, see the listener comment), so without this every notch was an + * instant multi-line jump. Wheel deltas accumulate into a pending line + * count and drain ~35% per animation frame (minimum one line, so it always + * terminates); more notches mid-glide just deepen the pending count, which + * reads as natural acceleration. Direction reversals cancel arithmetically. + * The pending amount is dropped when the active session changes mid-glide — + * leftover momentum must never scroll the tab the user just switched to. + */ + _smoothScrollBy(lines) { + if (!lines) return; + this._smoothScrollPending = (this._smoothScrollPending || 0) + lines; + this._smoothScrollSession = this.activeSessionId; + if (this._smoothScrollFrame) return; + const step = () => { + this._smoothScrollFrame = null; + const pending = this._smoothScrollPending || 0; + if (!pending) return; + if (this.activeSessionId !== this._smoothScrollSession) { + this._smoothScrollPending = 0; + return; + } + const move = + pending > 0 ? Math.max(1, Math.floor(pending * 0.35)) : Math.min(-1, Math.ceil(pending * 0.35)); + this._smoothScrollPending = pending - move; + this.terminal.scrollLines(move); + this._maybeLoadMoreHistoryOnScroll(move); + if (this._smoothScrollPending) this._smoothScrollFrame = requestAnimationFrame(step); + }; + this._smoothScrollFrame = requestAnimationFrame(step); + }, + /** * Hand a scroll gesture (wheel tick or touch drag, already converted to * lines) to the CLI as synthetic SGR wheel reports. SGR coordinates address From f262b8cb69abf041b726407a6eb0ade15dc2dc74 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 13:36:27 +0200 Subject: [PATCH 9/9] feat(terminal): gentler glide start and fractional wheel accumulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two smoothness refinements on the local wheel path: the drain factor drops from 35% to 22% per frame, so the first frame of a notch takes a smaller step and the glide lasts longer; and local scrolling accumulates FRACTIONAL lines (_wheelScrollLinesFloat) instead of rounding every event, so a slow macOS trackpad drag no longer snaps a whole line per tiny delta (the old ±1 fallback made slow drags scroll faster than the finger). Sub-line residuals stay pending until further input crosses a whole line. Forwarded SGR ticks keep the rounded integer path. Probe: a 20-line notch now glides through 14 positions to an exact landing; the 9-check scroll matrix still passes. Co-Authored-By: Claude Fable 5 --- src/web/public/terminal-ui.js | 51 ++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 71c673f2..99065d03 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -451,11 +451,14 @@ Object.assign(CodemanApp.prototype, { if (this.terminal?.buffer?.active?.type === 'alternate') return; ev.preventDefault(); ev.stopPropagation(); - const lines = this._wheelScrollLines(ev); if (this._shouldForwardWheelToApp(ev)) { - this._forwardScrollToApp(ev.clientX, ev.clientY, lines); + this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev)); return; } + // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits + // a stream of tiny pixel deltas, and rounding each one to a whole line + // (the ±1 fallback) made slow drags scroll faster than the finger. + const lines = this._wheelScrollLinesFloat(ev); this._noteTerminalUserScroll(lines); this._smoothScrollBy(lines); }, @@ -2081,11 +2084,15 @@ Object.assign(CodemanApp.prototype, { * wheel handler owns local scrolling (xterm's own smooth scroller is * bypassed, see the listener comment), so without this every notch was an * instant multi-line jump. Wheel deltas accumulate into a pending line - * count and drain ~35% per animation frame (minimum one line, so it always - * terminates); more notches mid-glide just deepen the pending count, which - * reads as natural acceleration. Direction reversals cancel arithmetically. - * The pending amount is dropped when the active session changes mid-glide — - * leftover momentum must never scroll the tab the user just switched to. + * count (fractional — see _wheelScrollLinesFloat) and drain ~22% per + * animation frame with a one-line floor, so a single notch starts with a + * gentle step and glides to an exact landing; more notches mid-glide deepen + * the pending count, which reads as natural acceleration. A sub-line + * residual stays pending until further input pushes it past a whole line + * (that is what makes slow trackpad drags track the finger). Direction + * reversals cancel arithmetically. The pending amount is dropped when the + * active session changes mid-glide — leftover momentum must never scroll + * the tab the user just switched to. */ _smoothScrollBy(lines) { if (!lines) return; @@ -2100,12 +2107,13 @@ Object.assign(CodemanApp.prototype, { this._smoothScrollPending = 0; return; } - const move = - pending > 0 ? Math.max(1, Math.floor(pending * 0.35)) : Math.min(-1, Math.ceil(pending * 0.35)); + if (Math.abs(pending) < 1) return; // sub-line residual: wait for more input + const eased = pending * 0.22; + const move = pending > 0 ? Math.max(1, Math.floor(eased)) : Math.min(-1, Math.ceil(eased)); this._smoothScrollPending = pending - move; this.terminal.scrollLines(move); this._maybeLoadMoreHistoryOnScroll(move); - if (this._smoothScrollPending) this._smoothScrollFrame = requestAnimationFrame(step); + if (Math.abs(this._smoothScrollPending) >= 1) this._smoothScrollFrame = requestAnimationFrame(step); }; this._smoothScrollFrame = requestAnimationFrame(step); }, @@ -2937,15 +2945,22 @@ Object.assign(CodemanApp.prototype, { // 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 lines = this._wheelScrollLinesFloat(ev); + if (!lines) return 0; // pure horizontal swipe: don't fall through to -1 + return Math.round(lines) || (lines > 0 ? 1 : -1); + }, + + /** Unrounded variant for the smooth local-scroll path, which accumulates + * sub-line fractions across events instead of forcing every tiny trackpad + * delta to a whole ±1 line. Same unit handling and Shift-axis trap. */ + _wheelScrollLinesFloat(ev) { const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; - 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); + if (!delta) return 0; + return 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) }, _shouldForwardWheelToApp(ev) {