diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index f8f2a51b..403bcc44 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -92,6 +92,36 @@ // Bound on page keys emitted from one gesture batch, mirroring the SGR tick // cap: a fling must not build a backlog that keeps paging after it stops. const PAGE_KEY_MAX_PER_BATCH = 3; + + // Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The + // body of the primary pane's _wheelScrollLinesFloat (see its comment for the + // Shift-axis trap and the deltaMode units), pure so a TerminalTile pages with + // the same math against its own row count. + function wheelDeltaLines(ev, rows) { + const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; + if (!delta) return 0; + return ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel) + ? delta + : ev.deltaMode === 2 // DOM_DELTA_PAGE + ? delta * (rows || 24) + : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) + } + + // Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds + // `lines` to the sub-page travel already `pending`, and returns the travel + // left over plus the keys to send ('' below one page). The arithmetic of the + // primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps + // its own pending travel) pages identically. + function pageKeysForTravel(pending, lines, rows) { + const perPage = Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION)); + const total = (pending || 0) + lines; + const pages = Math.trunc(total / perPage); + const keys = pages + ? (pages < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN).repeat(Math.min(Math.abs(pages), PAGE_KEY_MAX_PER_BATCH)) + : ''; + return { pending: total - pages * perPage, keys }; + } + const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4; // Composer navigation keys as xterm.js encodes user keystrokes: plain and // modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3 @@ -229,6 +259,8 @@ KEY_PAGE_DOWN, PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, + wheelDeltaLines, + pageKeysForTravel, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_TAP_SLOP, @@ -5395,42 +5427,51 @@ Object.assign(CodemanApp.prototype, { * Fails toward silence: an unknown or stale flag reports nothing rather than * injecting bytes. After a server restart the flag is false until the CLI * re-emits its DECSET, which closing and reopening a dialog does. + * + * `sessionId` defaults to the primary pane's session; a TerminalTile passes + * its own (through _handleDesktopTerminalClick's target), never the active one. */ - _shouldReportMouseToCli() { - return this.sessions?.get(this.activeSessionId)?.cliMouseTracking === true; + _shouldReportMouseToCli(sessionId = this.activeSessionId) { + return this.sessions?.get(sessionId)?.cliMouseTracking === true; }, // True when xterm's viewport shows the live PTY screen (not scrolled up into // local scrollback). SGR coordinates are only meaningful then: the TUI's // screen is the bottom `rows` of the buffer, so a report computed from a // scrolled-up viewport would hit-test a completely different row. - _terminalViewportAtBottom() { - const buf = this.terminal?.buffer?.active; + // `terminal` defaults to the primary pane's (a TerminalTile passes its own). + _terminalViewportAtBottom(terminal = this.terminal) { + const buf = terminal?.buffer?.active; return !buf || buf.viewportY >= buf.baseY; }, // Map a viewport point to a 1-based terminal cell the same way xterm maps a // click: offset inside .xterm-screen divided by the rendered cell size, // clamped to the grid. Returns null when the terminal isn't measurable yet. - _clientPointToCell(clientX, clientY) { - if (!this.terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; - const screen = this.terminal.element?.querySelector('.xterm-screen'); - const cell = this.terminal._core?._renderService?.dimensions?.css?.cell; + // `terminal` defaults to the primary pane's (a TerminalTile passes its own). + _clientPointToCell(clientX, clientY, terminal = this.terminal) { + if (!terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; + const screen = terminal.element?.querySelector('.xterm-screen'); + const cell = terminal._core?._renderService?.dimensions?.css?.cell; if (!screen || !cell?.width || !cell?.height) return null; const rect = screen.getBoundingClientRect(); - const col = Math.max(1, Math.min(this.terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1)); - const row = Math.max(1, Math.min(this.terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1)); + const col = Math.max(1, Math.min(terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1)); + const row = Math.max(1, Math.min(terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1)); return { col, row }; }, // Encode a tap as an SGR mouse report (press + release at button 0) and send it - // to the PTY directly, bypassing xterm's mouse encoder. - _sendSyntheticSgrTap(clientX, clientY) { - if (!this.activeSessionId) return; - if (!this._terminalViewportAtBottom()) return; // scrollback click → misfire, do nothing - const pos = this._clientPointToCell(clientX, clientY); + // to the PTY directly, bypassing xterm's mouse encoder. `target` ({ terminal, + // sessionId }) aims it at a TerminalTile instead of the primary pane; either + // field left out means the primary pane's. + _sendSyntheticSgrTap(clientX, clientY, target = {}) { + const sessionId = target.sessionId || this.activeSessionId; + const terminal = target.terminal || this.terminal; + if (!sessionId) return; + if (!this._terminalViewportAtBottom(terminal)) return; // scrollback click → misfire, do nothing + const pos = this._clientPointToCell(clientX, clientY, terminal); if (!pos) return; - this._sendInputAsync(this.activeSessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`); + this._sendInputAsync(sessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`); }, // True when a parsed CLI version string ('2.1.187' — banner-parsed on the @@ -5486,18 +5527,17 @@ Object.assign(CodemanApp.prototype, { /** 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. */ + * delta to a whole ±1 line. Same unit handling and Shift-axis trap. The + * math is the pure CodemanTerminalInput.wheelDeltaLines (top of this file), + * which a TerminalTile calls with its own row count. */ _wheelScrollLinesFloat(ev) { - const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; - 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) + return window.CodemanTerminalInput.wheelDeltaLines(ev, this.terminal?.rows); }, - _shouldForwardWheelToApp(ev) { + // `target` ({ terminal, sessionId }) asks the question for a TerminalTile: + // its own terminal's tracking mode and its own session, never the active one. + // Either field left out means the primary pane's. + _shouldForwardWheelToApp(ev, target = {}) { if (ev.shiftKey) return false; // Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the // plain wheel to xterm's own scrollback like pre-#144, for users who prefer @@ -5514,9 +5554,9 @@ Object.assign(CodemanApp.prototype, { // falls through to _maybePageCliTranscript, so the gesture still pages the // CLI's transcript and the setting keeps meaning exactly what it says. if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false; - const mode = this.terminal?.modes?.mouseTrackingMode; + const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode; if (mode && mode !== 'none') return false; - const session = this.sessions?.get(this.activeSessionId); + const session = this.sessions?.get(target.sessionId || this.activeSessionId); const sessionMode = session?.mode || 'claude'; if (sessionMode !== 'claude') return false; if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; @@ -5571,6 +5611,10 @@ Object.assign(CodemanApp.prototype, { * tmux send-keys server-side, so per-event writes would spawn a process storm * on a single flick; the queue is bounded so a wild scroll can't build a * backlog that keeps scrolling after the finger stops. + * + * A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes, + * terminal-tile.js: same 40ms window, same 512-byte bound) because this queue + * flushes to the active session only; keep the two in step. */ _queueScrollBytes(data) { if (!data || !this.activeSessionId) return; @@ -5601,13 +5645,20 @@ Object.assign(CodemanApp.prototype, { * Every other mode is deliberately absent: shell/pi own real terminal * scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour * is unverified (docs/scrollback-fix-plan.md). + * + * `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which + * calls this with its own session and terminal, so the mode list above stays + * here alone. `localRows` replaces `baseY` as the history row count: a tile + * discounts the stale rows its own load order leaves above the screen + * (TerminalTile._localRows). Every field left out means the primary pane's. */ - _localScrollbackIsHollow() { - const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; + _localScrollbackIsHollow(target = {}) { + const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude'; if (mode !== 'claude' && mode !== 'opencode') return false; - const buf = this.terminal?.buffer?.active; + const buf = (target.terminal || this.terminal)?.buffer?.active; if (!buf || buf.type === 'alternate') return false; - return (buf.baseY || 0) === 0; + const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY; + return (rows || 0) === 0; }, /** @@ -5631,6 +5682,10 @@ Object.assign(CodemanApp.prototype, { * * @returns true when the gesture was consumed here (the caller must not also * scroll locally). + * + * Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile + * through the same gates and the same pageKeysForTravel arithmetic; keep the + * two in step. */ _maybePageCliTranscript(ev, lines) { if (!lines || ev?.shiftKey || !this.activeSessionId) return false; @@ -5640,15 +5695,9 @@ Object.assign(CodemanApp.prototype, { this._pageKeySession = this.activeSessionId; this._pageKeyPending = 0; } - const tuning = window.CodemanTerminalInput; - const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION)); - const pending = (this._pageKeyPending || 0) + lines; - const pages = Math.trunc(pending / perPage); - this._pageKeyPending = pending - pages * perPage; - if (pages) { - const key = pages < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN; - this._queueScrollBytes(key.repeat(Math.min(Math.abs(pages), tuning.PAGE_KEY_MAX_PER_BATCH))); - } + const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows); + this._pageKeyPending = step.pending; + if (step.keys) this._queueScrollBytes(step.keys); this._logScrollRouting('page-keys'); return true; }, @@ -5703,18 +5752,24 @@ Object.assign(CodemanApp.prototype, { // synthetic SGR press could e.g. dismiss a claude permission dialog), // clicks outside the cell grid, and sessions where xterm's own encoder is // live (it reported the click itself — a second report would double-move). - _handleDesktopTerminalClick(ev) { - if (!this.terminal || !ev?.isTrusted) return; + // + // `target` ({ terminal, sessionId, linkHovered }) runs the same skips for a + // TerminalTile's click: its own terminal, its own session's tracking flag and + // its own link hover (the primary pane's _linkHovered belongs to its terminal + // alone). Every field left out means the primary pane's. + _handleDesktopTerminalClick(ev, target = {}) { + const terminal = target.terminal || this.terminal; + if (!terminal || !ev?.isTrusted) return; if (ev.button !== 0 || ev.detail !== 1) return; if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return; - const mode = this.terminal.modes?.mouseTrackingMode; + const mode = terminal.modes?.mouseTrackingMode; if (mode && mode !== 'none') return; - if (!this._shouldReportMouseToCli()) return; - if (this.terminal.hasSelection?.()) return; - if (this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider) + if (!this._shouldReportMouseToCli(target.sessionId)) return; + if (terminal.hasSelection?.()) return; + if (target.linkHovered ?? this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider) if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return; if (!ev.target?.closest?.('.xterm-screen')) return; - this._sendSyntheticSgrTap(ev.clientX, ev.clientY); + this._sendSyntheticSgrTap(ev.clientX, ev.clientY, target); }, /** diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 060b6668..8550b7b4 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -23,8 +23,10 @@ import { describe, expect, it, vi } from 'vitest'; function loadTerminalUiHarness() { const CodemanApp = function CodemanApp(this: any) {}; const logs: string[] = []; + // terminal-ui.js hangs CodemanTerminalInput off window; tests read it there. + const windowRef: Record = {}; const context = vm.createContext({ - window: {}, + window: windowRef, CodemanApp, console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) }, _crashDiag: { log: vi.fn() }, @@ -43,12 +45,12 @@ function loadTerminalUiHarness() { const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); vm.runInContext(code, context, { filename: 'terminal-ui.js' }); - return { app: new (CodemanApp as any)(), logs }; + return { app: new (CodemanApp as any)(), logs, windowRef }; } /** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */ function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { - const { app, logs } = loadTerminalUiHarness(); + const { app, logs, windowRef } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; app.sessions = new Map([ @@ -68,7 +70,7 @@ function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: numbe modes: { mouseTrackingMode: 'none' }, buffer: { active: { type: 'normal', viewportY: 0, baseY: 0, length: 36 } }, }; - return { app, sent, logs }; + return { app, sent, logs, windowRef }; } describe('full-history re-pull downgrade guard (issue #205 round 2)', () => { @@ -255,6 +257,80 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); }); +describe('the paging gates asked for another pane (a TerminalTile)', () => { + it('exports the paging math, and the primary pane runs on it', () => { + const { app, sent, windowRef } = hollowApp(); + const { wheelDeltaLines, pageKeysForTravel } = windowRef.CodemanTerminalInput; + + expect(wheelDeltaLines({ deltaY: -50, deltaMode: 0 }, 36)).toBe(-2); // pixels, 25 a line + expect(wheelDeltaLines({ deltaY: 3, deltaMode: 1 }, 36)).toBe(3); // lines (Firefox) + expect(wheelDeltaLines({ deltaY: 1, deltaMode: 2 }, 36)).toBe(36); // pages: the given rows + expect(wheelDeltaLines({ deltaY: 0, deltaX: -75, shiftKey: true, deltaMode: 0 }, 36)).toBe(-3); // Shift axis + expect(pageKeysForTravel(0, -10, 36)).toEqual({ pending: -10, keys: '' }); + expect(pageKeysForTravel(-10, -8, 36)).toEqual({ pending: 0, keys: '\x1b[5~' }); + expect(pageKeysForTravel(0, -1000, 36).keys).toBe('\x1b[5~'.repeat(3)); + expect(pageKeysForTravel(0, 40, 36)).toEqual({ pending: 4, keys: '\x1b[6~'.repeat(2) }); + + // The primary pane's own methods agree with them. + const ev = { deltaY: -250, deltaMode: 2 }; + expect(app._wheelScrollLinesFloat(ev)).toBe(wheelDeltaLines(ev, 36)); + let pending = 0; + let expected = ''; + for (const lines of [-10, -10, 30, -1000, 7]) { + const step = pageKeysForTravel(pending, lines, 36); + pending = step.pending; + expected += step.keys; + app._maybePageCliTranscript({ shiftKey: false }, lines); + } + app._flushWheelSgrQueue(); + expect(app._pageKeyPending).toBe(pending); + expect(expected).not.toBe(''); + expect(sent).toEqual([{ id: 'sess-1', data: expected }]); + }); + + it("_localScrollbackIsHollow reads the target's session, buffer and rows, never the active ones", () => { + const { app } = hollowApp({ mode: 'shell' }); // the ACTIVE session is a shell + app.sessions.set('tile-1', { mode: 'opencode' }); + const tileBuffer = { type: 'normal', viewportY: 16, baseY: 16 }; + const tileTerminal = { rows: 24, buffer: { active: tileBuffer } }; + + expect(app._localScrollbackIsHollow()).toBe(false); // the primary's own answer + // 16 rows above the tile's screen, all of them its own overflow: hollow. + expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(true); + // Real history in the tile: not hollow, whatever the primary holds. + expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 3 })).toBe(false); + // No localRows: the tile's own baseY decides. + expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal })).toBe(false); + tileBuffer.type = 'alternate'; + expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false); + tileBuffer.type = 'normal'; + app.sessions.set('tile-1', { mode: 'codex' }); + expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false); + }); + + it("_shouldForwardWheelToApp reads the target's session and the target terminal's tracking mode", () => { + const { app } = hollowApp({ mode: 'opencode' }); // the ACTIVE session would never forward + app.sessions.set('tile-1', { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true }); + const tileTerminal = { rows: 24, modes: { mouseTrackingMode: 'none' } }; + + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe( + true + ); + // The tile's own xterm encoder owns the wheel while its tracking is on. + tileTerminal.modes.mouseTrackingMode = 'any'; + expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe( + false + ); + // And the primary's tracking mode does not leak into the tile's answer. + tileTerminal.modes.mouseTrackingMode = 'none'; + app.terminal.modes.mouseTrackingMode = 'any'; + expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe( + true + ); + }); +}); + describe('scroll routing diagnostic (issue #205 round 2)', () => { it('prints the decision and its inputs once per session, and again when it changes', () => { const { app, logs } = hollowApp({ cliVersion: '2.1.100' }); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 6974c39e..28bbd254 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -577,6 +577,87 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']); }); + it('desktop click: a target aims the report at another pane (a TerminalTile)', () => { + // The tile's own terminal decides the geometry, the selection and the + // scroll position, and its own session decides the tracking flag; the + // primary pane's terminal and active session are not consulted. + const { app } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([ + ['sess-1', { mode: 'claude', cliMouseTracking: false }], + ['s2', { mode: 'opencode', cliMouseTracking: true }], + ]); + app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); + app._linkHovered = true; // the PRIMARY pane's hover: must not block the tile + app.terminal = { + cols: 80, + rows: 24, + modes: { mouseTrackingMode: 'none' }, + hasSelection: () => true, // the PRIMARY pane's selection: must not block the tile + buffer: { active: { viewportY: 0, baseY: 50 } }, // primary scrolled up: must not block either + element: { querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }) }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + let otherSelected = false; + const other = { + cols: 40, + rows: 12, + modes: { mouseTrackingMode: 'none' }, + hasSelection: () => otherSelected, + buffer: { active: { viewportY: 5, baseY: 5 } }, + element: { querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }) }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + const click = { + isTrusted: true, + button: 0, + detail: 1, + clientX: 171, + clientY: 101, + target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) }, + }; + + app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: false }); + expect(sent).toEqual([{ id: 's2', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]); + + // The tile's own selection and its own link hover do block it. + otherSelected = true; + app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: false }); + otherSelected = false; + app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: true }); + expect(sent).toHaveLength(1); + + // With no target the primary pane answers for itself, exactly as before. + expect(app._shouldReportMouseToCli()).toBe(false); + expect(app._shouldReportMouseToCli('s2')).toBe(true); + app._handleDesktopTerminalClick(click); + expect(sent).toHaveLength(1); + }); + + it("tap: a target uses that pane's geometry and scroll position", () => { + const { app } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); + app.terminal = null; // the primary pane need not even exist + const other = { + cols: 40, + rows: 12, + buffer: { active: { viewportY: 0, baseY: 5 } }, // scrolled up + element: { querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }) }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + app._sendSyntheticSgrTap(50, 9999, { terminal: other, sessionId: 's2' }); + expect(sent).toEqual([]); + + other.buffer.active.viewportY = 5; // back at the bottom + app._sendSyntheticSgrTap(50, 9999, { terminal: other, sessionId: 's2' }); + // Row clamped to the TARGET's 12 rows, not the primary's. + expect(sent).toEqual([{ id: 's2', data: '\x1b[<0;7;12M\x1b[<0;7;12m' }]); + }); + it('tap: does nothing while the viewport is scrolled up into local scrollback', () => { const { app } = loadTerminalUiHarness(); const sent: string[] = [];