diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 233632b3..2fabb633 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,7 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` **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 (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; 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`. -**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. **Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 39d04ee0..bb987d2e 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -24,7 +24,8 @@ * so the wheel pages the CLI's own transcript with PageUp/PageDown * (_maybePageCliTranscript) through the primary pane's gates, plus an * overflow-row discount for this pane's capture-before-resize load - * (_localRows). + * (_localRows), and only while the viewport is on the live screen (a + * wheel-down from those overflow rows is xterm's, and brings it home). * - The desktop click report: a plain left-click hand-encoded as SGR while * the session's CLI has mouse tracking on (cliMouseTracking), for the modes * whose mouse DECSETs the server strips (_installClickListener). @@ -41,7 +42,7 @@ * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) - * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_handleDesktopTerminalClick) + * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick) * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ @@ -887,6 +888,17 @@ // follow-up 4), so the wheel stays with xterm, as before. if (app._shouldForwardWheelToApp?.(ev, target)) return false; if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false; + // Only from the live screen. The one gate the primary pane never needs: a + // primary hollow buffer has baseY 0, so its viewport is always at the + // bottom, while a tile's is hollow with its own overflow rows still above + // the screen, and Shift+PageUp, a scrollbar drag or a wheel during the + // first replay can leave the viewport up there. Paging from there would + // swallow every wheel (wheel-down included) and keep the stale rows on + // screen while the CLI pages out of view; left to xterm, a wheel-down + // brings the viewport home and paging resumes from there. The click + // report refuses an off-bottom viewport for the same reason + // (_terminalViewportAtBottom). + if (!app._terminalViewportAtBottom?.(this.terminal)) return false; const lines = input.wheelDeltaLines(ev, this.terminal.rows); if (!lines) return false; const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 92d31442..64088a1d 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -5688,7 +5688,9 @@ Object.assign(CodemanApp.prototype, { * * Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile * through the same gates and the same pageKeysForTravel arithmetic; keep the - * two in step. + * two in step. The tile adds one gate this pane cannot need, viewport at the + * bottom: hollow here means baseY 0, so this viewport is always there, while a + * tile is hollow with its own discounted rows still above the screen. */ _maybePageCliTranscript(ev, lines) { if (!lines || ev?.shiftKey || !this.activeSessionId) return false; diff --git a/test/terminal-tile-scroll.browser.test.ts b/test/terminal-tile-scroll.browser.test.ts index a37ae39b..55eebce4 100644 --- a/test/terminal-tile-scroll.browser.test.ts +++ b/test/terminal-tile-scroll.browser.test.ts @@ -10,6 +10,9 @@ * screen, and a row-shrinking fit pushes more rows up, which is what the * tile's overflow discount (`_localRows`) counts. Output that scrolls real * lines is history, and the tile stops paging. + * - a viewport left up in those discounted rows gets its wheel back: a real + * wheel-down scrolls xterm home instead of being paged, and paging resumes + * from the live screen. * * The wheel is a real one (`page.mouse.wheel()`), and the last step proves it * reaches xterm: a wheel the tile does not page scrolls xterm's viewport, so @@ -69,14 +72,15 @@ describe('TerminalTile wheel paging in a real browser', () => { } as Snap; }); - /** A real wheel-up of a whole screen over the tile, then time for the 40 ms flush and a frame. */ - async function wheelUp(rows: number) { + /** A real wheel of `rows` lines over the tile (negative = up), then time for the 40 ms flush and a frame. */ + async function wheelBy(rows: number) { await page.mouse.move(200, 60); - await page.mouse.wheel(0, -rows * 25); + await page.mouse.wheel(0, rows * 25); await page.waitForTimeout(150); } + const wheelUp = (rows: number) => wheelBy(-rows); - it('pages a hollow tile, keeps xterm still, survives a shrink, and stops once real lines scroll', async () => { + it('pages a hollow tile, keeps xterm still, survives a shrink, returns an off-bottom wheel, and stops at real lines', async () => { await page.evaluate(async (id) => { const w = window as any; const app = w.app; @@ -147,6 +151,29 @@ describe('TerminalTile wheel paging in a real browser', () => { expect(afterSecondWheel.sent.length).toBeGreaterThan(afterWheel.sent.length); expect(afterSecondWheel.viewportY).toBe(afterShrink.baseY); + // A viewport left up in those rows (Shift+PageUp, a scrollbar drag): the + // wheel is xterm's again, so a wheel-down really scrolls it home and sends + // no page key, and from the live screen the wheel pages once more. + await page.evaluate(() => (window as any).__tileProbe.tile.terminal.scrollLines(-5)); + const scrolledUp = await snap(); + expect(scrolledUp.viewportY).toBe(afterShrink.baseY - 5); + expect(scrolledUp.localRows).toBe(0); + // xterm scrolls a few lines per real wheel event, so wheel down until home + // (bounded), each step really moving it and none of them paged. + let backHome = scrolledUp; + for (let i = 0; i < 10 && backHome.viewportY < afterShrink.baseY; i++) { + const before = backHome.viewportY; + await wheelBy(afterShrink.rows); + backHome = await snap(); + expect(backHome.viewportY).toBeGreaterThan(before); + } + expect(backHome.viewportY).toBe(afterShrink.baseY); + expect(backHome.sent.length).toBe(afterSecondWheel.sent.length); + await wheelUp(afterShrink.rows); + const pagedAgain = await snap(); + expect(pagedAgain.sent.length).toBeGreaterThan(backHome.sent.length); + expect(pagedAgain.viewportY).toBe(afterShrink.baseY); + // Output that scrolled real lines is history: the wheel goes back to // xterm, which scrolls its own buffer, and no page key is sent. await page.evaluate( @@ -159,7 +186,7 @@ describe('TerminalTile wheel paging in a real browser', () => { expect(afterOutput.localRows).toBe(3); await wheelUp(afterOutput.rows); const afterThirdWheel = await snap(); - expect(afterThirdWheel.sent.length).toBe(afterSecondWheel.sent.length); + expect(afterThirdWheel.sent.length).toBe(pagedAgain.sent.length); expect(afterThirdWheel.viewportY).toBeLessThan(afterOutput.baseY); } finally { await page.evaluate((id) => { diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 4a4e55d5..072ee16f 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -451,6 +451,38 @@ describe('rows the tile pushed above the screen itself are not history', () => { expect(flushed(ws)).toEqual([]); }); + it('leaves the wheel to xterm while the viewport sits in those rows, and pages again from the bottom', async () => { + // Hollow by the discount alone: baseY > 0, every row above the screen the + // tile's own. Shift+PageUp, a scrollbar drag or a wheel during the replay + // can leave the viewport up there; a primary hollow buffer (baseY 0) never + // can. Paging from there would swallow every wheel and keep the stale rows + // on screen, so xterm gets the wheel and a wheel-down brings it home. + serveCapture(lines(40), 40); + const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); + const baseY = term.buffer.active.baseY; + expect(baseY).toBe(16); + expect((tile as unknown as { _localRows(): number })._localRows()).toBe(0); + + term.buffer.active.viewportY = baseY - 5; + for (const ev of [wheelLines(-12), wheelLines(12), wheelLines(-5), wheelLines(-5)]) { + mount.fire('wheel', ev); + expect(ev.preventDefault).not.toHaveBeenCalled(); + expect(ev.stopPropagation).not.toHaveBeenCalled(); + } + expect(flushed(ws)).toEqual([]); + + // Back on the live screen, only travel made there counts: two quarter-screen + // wheels are one PageUp, with nothing carried over from the wheels xterm had + // (the gate sits before the pending travel is touched). + term.buffer.active.viewportY = baseY; + const first = wheelLines(-6); + mount.fire('wheel', first); + expect(first.preventDefault).toHaveBeenCalled(); + expect(flushed(ws)).toEqual([]); + mount.fire('wheel', wheelLines(-6)); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + it('keeps paging when the PTY geometry report reflows the rows above the screen', async () => { serveCapture(lines(40), 40); const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));