refactor(terminal): let the wheel paging and click-report gates answer for another pane

The primary pane's hollow-buffer paging (#555) and its desktop click report
read this.terminal and this.activeSessionId throughout, so a second pane (a
grid tile, the split's Pane B) could only get them by copying the gates and
their CLI rules. They now take an optional trailing target instead, the
pattern registerFilePathLinkProvider, copyTerminalSelection and
_handleImagePaste already use for tiles:

- _shouldForwardWheelToApp(ev, { terminal, sessionId })
- _localScrollbackIsHollow({ terminal, sessionId, localRows }), where
  localRows stands in for baseY so a tile can discount rows it pushed above
  the screen itself
- _handleDesktopTerminalClick(ev, { terminal, sessionId, linkHovered }),
  _sendSyntheticSgrTap(x, y, target), _shouldReportMouseToCli(sessionId),
  _terminalViewportAtBottom(terminal) and _clientPointToCell(x, y, terminal)

Every field left out means the primary pane's, and every existing caller
passes none, so the primary pane behaves exactly as before and its
grep-pinned call sites are unchanged. The mode list for hollow buffers and
the claude >= 2.1.187 forwarding gate stay in terminal-ui.js alone.

The stateless math moves into two pure exports on CodemanTerminalInput,
wheelDeltaLines and pageKeysForTravel, which _wheelScrollLinesFloat and
_maybePageCliTranscript now delegate to. Comments on both sides name the
tile's twins (the page-key pager and the 40 ms coalescer).

Tests: the exports agree with the primary pane's methods and bytes, the gates
read the target's session, buffer, rows and tracking mode rather than the
active ones, and a targeted click uses the target's geometry, selection,
scroll position and link hover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 07:18:47 +02:00
parent f855b5d274
commit 155372f7a8
3 changed files with 263 additions and 51 deletions
+102 -47
View File
@@ -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);
},
/**
+80 -4
View File
@@ -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<string, any> = {};
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' });
+81
View File
@@ -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[] = [];